From 8338657eaa3c965f4f260723cd59fffad9f3b73b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 19 Aug 2024 13:37:15 -0400 Subject: [PATCH] refactor(server): stacks (#11453) * refactor: stacks * mobile: get it built * chore: feedback * fix: sync and duplicates * mobile: remove old stack reference * chore: add primary asset id * revert change to asset entity * mobile: refactor mobile api * mobile: sync stack info after creating stack * mobile: update timeline after deleting stack * server: update asset updatedAt when stack is deleted * mobile: simplify action * mobile: rename to match dto property * fix: web test --------- Co-authored-by: Alex --- e2e/src/api/specs/asset.e2e-spec.ts | 157 +------ e2e/src/api/specs/stack.e2e-spec.ts | 211 ++++++++++ e2e/src/api/specs/user-admin.e2e-spec.ts | 6 +- mobile/assets/i18n/en-US.json | 4 +- mobile/lib/entities/asset.entity.dart | 56 +-- mobile/lib/entities/asset.entity.g.dart | Bin 90470 -> 95839 bytes .../lib/pages/common/gallery_viewer.page.dart | 2 +- mobile/lib/providers/asset.provider.dart | 4 +- .../asset_viewer/asset_stack.provider.dart | 2 +- mobile/lib/services/api.service.dart | 2 + mobile/lib/services/asset_stack.service.dart | 72 ---- mobile/lib/services/stack.service.dart | 79 ++++ .../widgets/asset_grid/multiselect_grid.dart | 10 +- .../widgets/asset_grid/thumbnail_image.dart | 8 +- .../asset_viewer/bottom_gallery_bar.dart | 88 +--- mobile/openapi/README.md | Bin 31475 -> 32028 bytes mobile/openapi/lib/api.dart | Bin 10793 -> 10932 bytes mobile/openapi/lib/api/assets_api.dart | Bin 34537 -> 33309 bytes mobile/openapi/lib/api/stacks_api.dart | Bin 0 -> 9223 bytes mobile/openapi/lib/api_client.dart | Bin 28090 -> 28336 bytes .../lib/model/asset_bulk_update_dto.dart | Bin 9010 -> 7526 bytes .../openapi/lib/model/asset_response_dto.dart | Bin 14078 -> 13366 bytes .../lib/model/asset_stack_response_dto.dart | Bin 0 -> 3415 bytes mobile/openapi/lib/model/permission.dart | Bin 13987 -> 14601 bytes .../openapi/lib/model/stack_create_dto.dart | Bin 0 -> 2938 bytes .../openapi/lib/model/stack_response_dto.dart | Bin 0 -> 3327 bytes .../openapi/lib/model/stack_update_dto.dart | Bin 0 -> 3295 bytes .../lib/model/update_stack_parent_dto.dart | Bin 3204 -> 0 bytes mobile/test/fixtures/asset.stub.dart | 2 - .../extensions/asset_extensions_test.dart | 1 - .../home/asset_grid_data_structure_test.dart | 1 - .../modules/shared/sync_service_test.dart | 1 - open-api/immich-openapi-specs.json | 390 ++++++++++++++---- open-api/typescript-sdk/src/fetch-client.ts | 104 ++++- server/src/controllers/asset.controller.ts | 8 - server/src/controllers/index.ts | 2 + server/src/controllers/stack.controller.ts | 57 +++ server/src/cores/access.core.ts | 12 + server/src/dtos/asset-response.dto.ts | 34 +- server/src/dtos/asset.dto.ts | 6 - server/src/dtos/stack.dto.ts | 41 +- server/src/enum.ts | 5 + server/src/interfaces/access.interface.ts | 4 + server/src/interfaces/stack.interface.ts | 9 +- server/src/queries/access.repository.sql | 11 + server/src/repositories/access.repository.ts | 29 +- server/src/repositories/stack.repository.ts | 131 +++++- server/src/services/asset.service.spec.ts | 190 +-------- server/src/services/asset.service.ts | 92 +---- server/src/services/duplicate.service.ts | 2 +- server/src/services/index.ts | 2 + server/src/services/stack.service.ts | 84 ++++ server/test/fixtures/shared-link.stub.ts | 1 - .../repositories/access.repository.mock.ts | 15 +- .../repositories/stack.repository.mock.ts | 2 + .../actions/unstack-action.svelte | 8 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 15 +- .../asset-viewer/asset-viewer.svelte | 50 +-- .../assets/thumbnail/thumbnail.svelte | 4 +- .../photos-page/actions/stack-action.svelte | 5 +- .../duplicates/duplicate-asset.svelte | 7 +- web/src/lib/utils/asset-utils.ts | 106 ++--- web/src/test-data/factories/asset-factory.ts | 1 - 63 files changed, 1274 insertions(+), 859 deletions(-) create mode 100644 e2e/src/api/specs/stack.e2e-spec.ts delete mode 100644 mobile/lib/services/asset_stack.service.dart create mode 100644 mobile/lib/services/stack.service.dart create mode 100644 mobile/openapi/lib/api/stacks_api.dart create mode 100644 mobile/openapi/lib/model/asset_stack_response_dto.dart create mode 100644 mobile/openapi/lib/model/stack_create_dto.dart create mode 100644 mobile/openapi/lib/model/stack_response_dto.dart create mode 100644 mobile/openapi/lib/model/stack_update_dto.dart delete mode 100644 mobile/openapi/lib/model/update_stack_parent_dto.dart create mode 100644 server/src/controllers/stack.controller.ts create mode 100644 server/src/services/stack.service.ts diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 4ee035ee95..5bd52b437e 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -7,7 +7,6 @@ import { SharedLinkType, getAssetInfo, getMyUser, - updateAssets, } from '@immich/sdk'; import { exiftool } from 'exiftool-vendored'; import { DateTime } from 'luxon'; @@ -67,11 +66,9 @@ describe('/asset', () => { let timeBucketUser: LoginResponseDto; let quotaUser: LoginResponseDto; let statsUser: LoginResponseDto; - let stackUser: LoginResponseDto; let user1Assets: AssetMediaResponseDto[]; let user2Assets: AssetMediaResponseDto[]; - let stackAssets: AssetMediaResponseDto[]; let locationAsset: AssetMediaResponseDto; let ratingAsset: AssetMediaResponseDto; @@ -79,14 +76,13 @@ describe('/asset', () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - [websocket, user1, user2, statsUser, quotaUser, timeBucketUser, stackUser] = await Promise.all([ + [websocket, user1, user2, statsUser, quotaUser, timeBucketUser] = await Promise.all([ utils.connectWebsocket(admin.accessToken), utils.userSetup(admin.accessToken, createUserDto.create('1')), utils.userSetup(admin.accessToken, createUserDto.create('2')), utils.userSetup(admin.accessToken, createUserDto.create('stats')), utils.userSetup(admin.accessToken, createUserDto.userQuota), utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), - utils.userSetup(admin.accessToken, createUserDto.create('stack')), ]); await utils.createPartner(user1.accessToken, user2.userId); @@ -149,20 +145,6 @@ describe('/asset', () => { }), ]); - // stacks - stackAssets = await Promise.all([ - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - ]); - - await updateAssets( - { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, - { headers: asBearerAuth(stackUser.accessToken) }, - ); - const person1 = await utils.createPerson(user1.accessToken, { name: 'Test Person', }); @@ -826,145 +808,8 @@ describe('/asset', () => { expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); - - it('should require a valid parent id', async () => { - const { status, body } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID'])); - }); - - it('should require access to the parent', async () => { - const { status, body } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should add stack children', async () => { - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })])); - }); - - it('should remove stack children', async () => { - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ removeParent: true, ids: [stackAssets[1].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stackAssets[2].id }), - expect.objectContaining({ id: stackAssets[3].id }), - ]), - ); - }); - - it('should remove all stack children', async () => { - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).toBeUndefined(); - }); - - it('should merge stack children', async () => { - // create stack after previous test removed stack children - await updateAssets( - { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, - { headers: asBearerAuth(stackUser.accessToken) }, - ); - - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stackAssets[0].id }), - expect.objectContaining({ id: stackAssets[1].id }), - expect.objectContaining({ id: stackAssets[2].id }), - ]), - ); - }); }); - describe('PUT /assets/stack/parent', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put('/assets/stack/parent'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should require a valid id', async () => { - const { status, body } = await request(app) - .put('/assets/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); - }); - - it('should require access', async () => { - const { status, body } = await request(app) - .put('/assets/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should make old parent child of new parent', async () => { - const { status } = await request(app) - .put('/assets/stack/parent') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); - - expect(status).toBe(200); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - - // new parent - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stackAssets[1].id }), - expect.objectContaining({ id: stackAssets[2].id }), - expect.objectContaining({ id: stackAssets[3].id }), - ]), - ); - }); - }); describe('POST /assets', () => { beforeAll(setupTests, 30_000); diff --git a/e2e/src/api/specs/stack.e2e-spec.ts b/e2e/src/api/specs/stack.e2e-spec.ts new file mode 100644 index 0000000000..bf34369ee3 --- /dev/null +++ b/e2e/src/api/specs/stack.e2e-spec.ts @@ -0,0 +1,211 @@ +import { AssetMediaResponseDto, LoginResponseDto, searchStacks } from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/stacks', () => { + let admin: LoginResponseDto; + let user1: LoginResponseDto; + let user2: LoginResponseDto; + let asset: AssetMediaResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + + admin = await utils.adminSetup(); + + [user1, user2] = await Promise.all([ + utils.userSetup(admin.accessToken, createUserDto.user1), + utils.userSetup(admin.accessToken, createUserDto.user2), + ]); + + asset = await utils.createAsset(user1.accessToken); + }); + + describe('POST /stacks', () => { + it('should require authentication', async () => { + const { status, body } = await request(app) + .post('/stacks') + .send({ assetIds: [asset.id] }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require at least two assets', async () => { + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [uuidDto.invalid, uuidDto.invalid] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + + it('should require access', async () => { + const user2Asset = await utils.createAsset(user2.accessToken); + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset.id, user2Asset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should create a stack', async () => { + const [asset1, asset2] = await Promise.all([ + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + ]); + + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset1.id, asset2.id] }); + + expect(status).toBe(201); + expect(body).toEqual({ + id: expect.any(String), + primaryAssetId: asset1.id, + assets: [expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id })], + }); + }); + + it('should merge an existing stack', async () => { + const [asset1, asset2, asset3] = await Promise.all([ + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + ]); + + const response1 = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset1.id, asset2.id] }); + + expect(response1.status).toBe(201); + + const stacksBefore = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) }); + + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset1.id, asset3.id] }); + + expect(status).toBe(201); + expect(body).toEqual({ + id: expect.any(String), + primaryAssetId: asset1.id, + assets: expect.arrayContaining([ + expect.objectContaining({ id: asset1.id }), + expect.objectContaining({ id: asset2.id }), + expect.objectContaining({ id: asset3.id }), + ]), + }); + + const stacksAfter = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) }); + expect(stacksAfter.length).toBe(stacksBefore.length); + }); + + // it('should require a valid parent id', async () => { + // const { status, body } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${user1.accessToken}`) + // .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID'])); + // }); + }); + + // it('should require access to the parent', async () => { + // const { status, body } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${user1.accessToken}`) + // .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.noPermission); + // }); + + // it('should add stack children', async () => { + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).not.toBeUndefined(); + // expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })])); + // }); + + // it('should remove stack children', async () => { + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ removeParent: true, ids: [stackAssets[1].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).not.toBeUndefined(); + // expect(asset.stack).toEqual( + // expect.arrayContaining([ + // expect.objectContaining({ id: stackAssets[2].id }), + // expect.objectContaining({ id: stackAssets[3].id }), + // ]), + // ); + // }); + + // it('should remove all stack children', async () => { + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).toBeUndefined(); + // }); + + // it('should merge stack children', async () => { + // // create stack after previous test removed stack children + // await updateAssets( + // { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, + // { headers: asBearerAuth(stackUser.accessToken) }, + // ); + + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).not.toBeUndefined(); + // expect(asset.stack).toEqual( + // expect.arrayContaining([ + // expect.objectContaining({ id: stackAssets[0].id }), + // expect.objectContaining({ id: stackAssets[1].id }), + // expect.objectContaining({ id: stackAssets[2].id }), + // ]), + // ); + // }); +}); diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index b7147f52cc..8a417387e7 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -1,11 +1,11 @@ import { LoginResponseDto, + createStack, deleteUserAdmin, getMyUser, getUserAdmin, getUserPreferencesAdmin, login, - updateAssets, } from '@immich/sdk'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; @@ -321,8 +321,8 @@ describe('/admin/users', () => { utils.createAsset(user.accessToken), ]); - await updateAssets( - { assetBulkUpdateDto: { stackParentId: asset1.id, ids: [asset2.id] } }, + await createStack( + { stackCreateDto: { assetIds: [asset1.id, asset2.id] } }, { headers: asBearerAuth(user.accessToken) }, ); diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index f9dd86513d..decb0a72e1 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -573,7 +573,5 @@ "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", - "viewer_remove_from_stack": "Remove from Stack", - "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} \ No newline at end of file +} diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 3f8c1fa74c..97e10b3d20 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -33,11 +33,13 @@ class Asset { isArchived = remote.isArchived, isTrashed = remote.isTrashed, isOffline = remote.isOffline, - // workaround to nullify stackParentId for the parent asset until we refactor the mobile app + // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app // stack handling to properly handle it - stackParentId = - remote.stackParentId == remote.id ? null : remote.stackParentId, - stackCount = remote.stackCount, + stackPrimaryAssetId = remote.stack?.primaryAssetId == remote.id + ? null + : remote.stack?.primaryAssetId, + stackCount = remote.stack?.assetCount ?? 0, + stackId = remote.stack?.id, thumbhash = remote.thumbhash; Asset.local(AssetEntity local, List hash) @@ -86,7 +88,8 @@ class Asset { this.isFavorite = false, this.isArchived = false, this.isTrashed = false, - this.stackParentId, + this.stackId, + this.stackPrimaryAssetId, this.stackCount = 0, this.isOffline = false, this.thumbhash, @@ -163,12 +166,11 @@ class Asset { @ignore ExifInfo? exifInfo; - String? stackParentId; + String? stackId; - @ignore - int get stackChildrenCount => stackCount ?? 0; + String? stackPrimaryAssetId; - int? stackCount; + int stackCount; /// Aspect ratio of the asset @ignore @@ -231,7 +233,8 @@ class Asset { isArchived == other.isArchived && isTrashed == other.isTrashed && stackCount == other.stackCount && - stackParentId == other.stackParentId; + stackPrimaryAssetId == other.stackPrimaryAssetId && + stackId == other.stackId; } @override @@ -256,7 +259,8 @@ class Asset { isArchived.hashCode ^ isTrashed.hashCode ^ stackCount.hashCode ^ - stackParentId.hashCode; + stackPrimaryAssetId.hashCode ^ + stackId.hashCode; /// Returns `true` if this [Asset] can updated with values from parameter [a] bool canUpdate(Asset a) { @@ -269,7 +273,6 @@ class Asset { width == null && a.width != null || height == null && a.height != null || livePhotoVideoId == null && a.livePhotoVideoId != null || - stackParentId == null && a.stackParentId != null || isFavorite != a.isFavorite || isArchived != a.isArchived || isTrashed != a.isTrashed || @@ -278,10 +281,9 @@ class Asset { a.exifInfo?.longitude != exifInfo?.longitude || // no local stack count or different count from remote a.thumbhash != thumbhash || - ((stackCount == null && a.stackCount != null) || - (stackCount != null && - a.stackCount != null && - stackCount != a.stackCount)); + stackId != a.stackId || + stackCount != a.stackCount || + stackPrimaryAssetId == null && a.stackPrimaryAssetId != null; } /// Returns a new [Asset] with values from this and merged & updated with [a] @@ -311,9 +313,11 @@ class Asset { id: id, remoteId: remoteId, livePhotoVideoId: livePhotoVideoId, - // workaround to nullify stackParentId for the parent asset until we refactor the mobile app + // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app // stack handling to properly handle it - stackParentId: stackParentId == remoteId ? null : stackParentId, + stackId: stackId, + stackPrimaryAssetId: + stackPrimaryAssetId == remoteId ? null : stackPrimaryAssetId, stackCount: stackCount, isFavorite: isFavorite, isArchived: isArchived, @@ -330,9 +334,12 @@ class Asset { width: a.width, height: a.height, livePhotoVideoId: a.livePhotoVideoId, - // workaround to nullify stackParentId for the parent asset until we refactor the mobile app + // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app // stack handling to properly handle it - stackParentId: a.stackParentId == a.remoteId ? null : a.stackParentId, + stackId: a.stackId, + stackPrimaryAssetId: a.stackPrimaryAssetId == a.remoteId + ? null + : a.stackPrimaryAssetId, stackCount: a.stackCount, // isFavorite + isArchived are not set by device-only assets isFavorite: a.isFavorite, @@ -374,7 +381,8 @@ class Asset { bool? isTrashed, bool? isOffline, ExifInfo? exifInfo, - String? stackParentId, + String? stackId, + String? stackPrimaryAssetId, int? stackCount, String? thumbhash, }) => @@ -398,7 +406,8 @@ class Asset { isTrashed: isTrashed ?? this.isTrashed, isOffline: isOffline ?? this.isOffline, exifInfo: exifInfo ?? this.exifInfo, - stackParentId: stackParentId ?? this.stackParentId, + stackId: stackId ?? this.stackId, + stackPrimaryAssetId: stackPrimaryAssetId ?? this.stackPrimaryAssetId, stackCount: stackCount ?? this.stackCount, thumbhash: thumbhash ?? this.thumbhash, ); @@ -445,8 +454,9 @@ class Asset { "checksum": "$checksum", "ownerId": $ownerId, "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}", + "stackId": "${stackId ?? "N/A"}", + "stackPrimaryAssetId": "${stackPrimaryAssetId ?? "N/A"}", "stackCount": "$stackCount", - "stackParentId": "${stackParentId ?? "N/A"}", "fileCreatedAt": "$fileCreatedAt", "fileModifiedAt": "$fileModifiedAt", "updatedAt": "$updatedAt", diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index 099e15eef15ca6b09d57f8e2f8a81a33a8161a4c..23bf23604635ddfdc7105d2f328aefa1f7770e8b 100644 GIT binary patch delta 1519 zcmah}?MqW(6z4Ioqq`})zSP#-i_;)Mhe|J+lWk#;mAbMrT(i_AYA>4ZLkdz#NYFCt zL=P5`Un25DH3p67MT*KVrk0>(&0ZCXi2MVrp65EZdv_<@PtQ5$_xqi5&pFRC_l)`Y zlet&S#+0}qO`(G(b`?E#$y6}FrhtWwi*Ic_R@2&UYim2~4uU?gTvD66rtXMc99v|W zVXHh5ezBD>A?IM9yb~-64!+5&V)Al)R>gN2j3pjdM)G~8U?h-(Hh9mq`npPtMq3P|)DWOx)J^5x#|TdqT7 zwo=6uX!e&a$DossPAP}KKUVsTa~1h`UWNAHF&$#Cw*YO!_5>7%Tc|l>Mow%iLM0)e zsMHWq>=lU!_Xr2f=%Apn*jj|P^V0rVJt|BM>G6e>F%|Ur3Kdb#@wV`l2oWR&lqn;@ zk;(lec@KwkOHs$-)xh3T)SwW_)1YKK{PuE$67cA5l#J`0=pjDjK(%<>i5Su++kUhT XTOCLvyAfh&F z`xu82Vo(IJm*m2Pd?+H?U~BzZ53M45N|3Vcp?ZiQ^{TVE^K{Pl@BBFDOMhd&K4Ts^ zl?u0S^y0`^FG6w?{K}?1rE>t23IlSC9*5a>d{Ank`_18oLS4k>*;brqIlN@Ykz%zN zPz~U;iW6U`HWYFx0dd`kIHf-%tK2YdXvIb|3p3vV1FwRIx1x_{5#`(J>|Np_|D;}T zRkq|;WX$IuE)UY(f{@uHiwxS2A!|BHIw1&7avTBS3~?YKSPSN~a9M_1W)4FK=g?&d zpu?ig+gK=#YAl=gA!adRMbIE>ZV+{Cs}HexeP;GOgXwpTw3{QR;ND`e`I6DJF)Zz^K-f9cnZN%e_-S|7}8ft7JX^C6ie*D688M&h3W~(@zcrvZad&@3is1JwycvZQ8_KQ! z9}D&U<*EQl<=xtGIdTt{@~YCnpOZ8BHAf&XRVFxaiTLQ#QNolxi4bun-$Y2X?jLcL BT_*ql diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index cc62620dfb..d8ea7cd89b 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -68,7 +68,7 @@ class GalleryViewerPage extends HookConsumerWidget { }); final stackIndex = useState(-1); - final stack = showStack && currentAsset.stackChildrenCount > 0 + final stack = showStack && currentAsset.stackCount > 0 ? ref.watch(assetStackStateProvider(currentAsset)) : []; final stackElements = showStack ? [currentAsset, ...stack] : []; diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index a0a3879db5..3c1a5ecc01 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -360,7 +360,7 @@ QueryBuilder? getRemoteAssetQuery(WidgetRef ref) { .filter() .ownerIdEqualTo(userId) .isTrashedEqualTo(false) - .stackParentIdIsNull() + .stackPrimaryAssetIdIsNull() .sortByFileCreatedAtDesc(); } @@ -374,6 +374,6 @@ QueryBuilder _commonFilterAndSort( .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) - .stackParentIdIsNull() + .stackPrimaryAssetIdIsNull() .sortByFileCreatedAtDesc(); } diff --git a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart index 0883ed92db..c3e4414b39 100644 --- a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart @@ -48,7 +48,7 @@ final assetStackProvider = .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) - .stackParentIdEqualTo(asset.remoteId) + .stackPrimaryAssetIdEqualTo(asset.remoteId) .sortByFileCreatedAtDesc() .findAll(); }); diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index c128a2c2fc..6ff62d4b3a 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -29,6 +29,7 @@ class ApiService implements Authentication { late ActivitiesApi activitiesApi; late DownloadApi downloadApi; late TrashApi trashApi; + late StacksApi stacksApi; ApiService() { final endpoint = Store.tryGet(StoreKey.serverEndpoint); @@ -61,6 +62,7 @@ class ApiService implements Authentication { activitiesApi = ActivitiesApi(_apiClient); downloadApi = DownloadApi(_apiClient); trashApi = TrashApi(_apiClient); + stacksApi = StacksApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { diff --git a/mobile/lib/services/asset_stack.service.dart b/mobile/lib/services/asset_stack.service.dart deleted file mode 100644 index 9eff495f37..0000000000 --- a/mobile/lib/services/asset_stack.service.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:openapi/api.dart'; - -class AssetStackService { - AssetStackService(this._api); - - final ApiService _api; - - Future updateStack( - Asset parentAsset, { - List? childrenToAdd, - List? childrenToRemove, - }) async { - // Guard [local asset] - if (parentAsset.remoteId == null) { - return; - } - - try { - if (childrenToAdd != null) { - final toAdd = childrenToAdd - .where((e) => e.isRemote) - .map((e) => e.remoteId!) - .toList(); - - await _api.assetsApi.updateAssets( - AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId), - ); - } - - if (childrenToRemove != null) { - final toRemove = childrenToRemove - .where((e) => e.isRemote) - .map((e) => e.remoteId!) - .toList(); - await _api.assetsApi.updateAssets( - AssetBulkUpdateDto(ids: toRemove, removeParent: true), - ); - } - } catch (error) { - debugPrint("Error while updating stack children: ${error.toString()}"); - } - } - - Future updateStackParent(Asset oldParent, Asset newParent) async { - // Guard [local asset] - if (oldParent.remoteId == null || newParent.remoteId == null) { - return; - } - - try { - await _api.assetsApi.updateStackParent( - UpdateStackParentDto( - oldParentId: oldParent.remoteId!, - newParentId: newParent.remoteId!, - ), - ); - } catch (error) { - debugPrint("Error while updating stack parent: ${error.toString()}"); - } - } -} - -final assetStackServiceProvider = Provider( - (ref) => AssetStackService( - ref.watch(apiServiceProvider), - ), -); diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart new file mode 100644 index 0000000000..75074101c2 --- /dev/null +++ b/mobile/lib/services/stack.service.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:isar/isar.dart'; +import 'package:openapi/api.dart'; + +class StackService { + StackService(this._api, this._db); + + final ApiService _api; + final Isar _db; + + Future getStack(String stackId) async { + try { + return _api.stacksApi.getStack(stackId); + } catch (error) { + debugPrint("Error while fetching stack: $error"); + } + return null; + } + + Future createStack(List assetIds) async { + try { + return _api.stacksApi.createStack( + StackCreateDto(assetIds: assetIds), + ); + } catch (error) { + debugPrint("Error while creating stack: $error"); + } + return null; + } + + Future updateStack( + String stackId, + String primaryAssetId, + ) async { + try { + return await _api.stacksApi.updateStack( + stackId, + StackUpdateDto(primaryAssetId: primaryAssetId), + ); + } catch (error) { + debugPrint("Error while updating stack children: $error"); + } + return null; + } + + Future deleteStack(String stackId, List assets) async { + try { + await _api.stacksApi.deleteStack(stackId); + + // Update local database to trigger rerendering + final List removeAssets = []; + for (final asset in assets) { + asset.stackId = null; + asset.stackPrimaryAssetId = null; + asset.stackCount = 0; + + removeAssets.add(asset); + } + + _db.writeTxn(() async { + await _db.assets.putAll(removeAssets); + }); + } catch (error) { + debugPrint("Error while deleting stack: $error"); + } + } +} + +final stackServiceProvider = Provider( + (ref) => StackService( + ref.watch(apiServiceProvider), + ref.watch(dbProvider), + ), +); diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index e50a9a5ece..3263373554 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/services/asset_stack.service.dart'; +import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; @@ -344,11 +344,9 @@ class MultiselectGrid extends HookConsumerWidget { if (!selectionEnabledHook.value || selection.value.length < 2) { return; } - final parent = selection.value.elementAt(0); - selection.value.remove(parent); - await ref.read(assetStackServiceProvider).updateStack( - parent, - childrenToAdd: selection.value.toList(), + + await ref.read(stackServiceProvider).createStack( + selection.value.map((e) => e.remoteId!).toList(), ); } finally { processing.value = false; diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart index 2480f44278..8e818f64fb 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart @@ -107,16 +107,16 @@ class ThumbnailImage extends ConsumerWidget { right: 8, child: Row( children: [ - if (asset.stackChildrenCount > 1) + if (asset.stackCount > 1) Text( - "${asset.stackChildrenCount}", + "${asset.stackCount}", style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), - if (asset.stackChildrenCount > 1) + if (asset.stackCount > 1) const SizedBox( width: 3, ), @@ -208,7 +208,7 @@ class ThumbnailImage extends ConsumerWidget { ), ), if (!asset.isImage) buildVideoIcon(), - if (asset.stackChildrenCount > 0) buildStackIcon(), + if (asset.stackCount > 0) buildStackIcon(), ], ); } diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index fb70ac309e..7d9e49bd29 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/services/asset_stack.service.dart'; +import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; @@ -49,11 +49,10 @@ class BottomGalleryBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; - final stack = showStack && asset.stackChildrenCount > 0 + final stackItems = showStack && asset.stackCount > 0 ? ref.watch(assetStackStateProvider(asset)) : []; - final stackElements = showStack ? [asset, ...stack] : []; - bool isParent = stackIndex == -1 || stackIndex == 0; + bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null; final navStack = AutoRouter.of(context).stackData; final isTrashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); @@ -76,7 +75,7 @@ class BottomGalleryBar extends ConsumerWidget { {asset}, force: force, ); - if (isDeleted && isParent) { + if (isDeleted && isStackPrimaryAsset) { // Workaround for asset remaining in the gallery renderList.deleteAsset(asset); @@ -98,7 +97,7 @@ class BottomGalleryBar extends ConsumerWidget { final isDeleted = await onDelete(false); if (isDeleted) { // Can only trash assets stored in server. Local assets are always permanently removed for now - if (context.mounted && asset.isRemote && isParent) { + if (context.mounted && asset.isRemote && isStackPrimaryAsset) { ImmichToast.show( durationInSecond: 1, context: context, @@ -127,6 +126,16 @@ class BottomGalleryBar extends ConsumerWidget { ); } + unStack() async { + if (asset.stackId == null) { + return; + } + + await ref + .read(stackServiceProvider) + .deleteStack(asset.stackId!, [asset, ...stackItems]); + } + void showStackActionItems() { showModalBottomSheet( context: context, @@ -138,74 +147,13 @@ class BottomGalleryBar extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (!isParent) - ListTile( - leading: const Icon( - Icons.bookmark_border_outlined, - size: 24, - ), - onTap: () async { - await ref - .read(assetStackServiceProvider) - .updateStackParent( - asset, - stackElements.elementAt(stackIndex), - ); - ctx.pop(); - context.maybePop(); - }, - title: const Text( - "viewer_stack_use_as_main_asset", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), - ListTile( - leading: const Icon( - Icons.copy_all_outlined, - size: 24, - ), - onTap: () async { - if (isParent) { - await ref - .read(assetStackServiceProvider) - .updateStackParent( - asset, - stackElements - .elementAt(1), // Next asset as parent - ); - // Remove itself from stack - await ref.read(assetStackServiceProvider).updateStack( - stackElements.elementAt(1), - childrenToRemove: [asset], - ); - ctx.pop(); - context.maybePop(); - } else { - await ref.read(assetStackServiceProvider).updateStack( - asset, - childrenToRemove: [ - stackElements.elementAt(stackIndex), - ], - ); - removeAssetFromStack(); - ctx.pop(); - } - }, - title: const Text( - "viewer_remove_from_stack", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), ListTile( leading: const Icon( Icons.filter_none_outlined, size: 18, ), onTap: () async { - await ref.read(assetStackServiceProvider).updateStack( - asset, - childrenToRemove: stack, - ); + await unStack(); ctx.pop(); context.maybePop(); }, @@ -255,7 +203,7 @@ class BottomGalleryBar extends ConsumerWidget { handleArchive() { ref.read(assetProvider.notifier).toggleArchive([asset]); - if (isParent) { + if (isStackPrimaryAsset) { context.maybePop(); return; } @@ -346,7 +294,7 @@ class BottomGalleryBar extends ConsumerWidget { tooltip: 'control_bottom_app_bar_archive'.tr(), ): (_) => handleArchive(), }, - if (isOwner && stack.isNotEmpty) + if (isOwner && asset.stackCount > 0) { BottomNavigationBarItem( icon: const Icon(Icons.burst_mode_outlined), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 657dad9d5b33b87dee428edf5a271c65615738e9..f2effe1c2060bd4d89a21cefdb5ea6813384b2d3 100644 GIT binary patch delta 629 zcmezTm2u85#tk-Nn^%fusMnVyCTABr7G!EE)F?!2X(bn>CYGcIgG9BoVl`6ollAq% zJdhf_+!SSqf?|*YO`sMntpNYv5G^eQeXvk5P>4$lt^sOjN@`9j4nyl9rb1L8nd;)| z;~L_MFjv1iGo_Z0;l+v`{J+%bv6YQn}Ws%(L?urx$xE))Znpl*a z0X7xlSSySGhA4qJ3*uT7vk_r1Szk=d2)o-#3sQiwh~&J0P`L9pv)F}6G9_nEUKk?I z3UK5};Uh2B!d*l6<7tpPZN@!UB?cXcdF_1z^6WLXCo!RzPTomX?Bk zVsUY5NwGdyS|2PwxlvGTvz1tu`sNMxo|2PUaz!TdMsjTSkCYXg+*ioG`G5W$b~Pm9 YT}tv}HB$1E_4Pvw&}`m(vOJg-01@ms?*IS* diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 4d33f1018cb52343301dc8853282fe3c995565db..6ee06d53042bb0814bb018dca1a522b97cd1e2a8 100644 GIT binary patch delta 80 zcmZ1(vL$qbEE`KnVsiH6KxXmHGHm+nOv%}s6?tb0LPY&V_L0SZeJPyhe` diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index d7d386130bc0269a2c8f82c6ab1d304e06d1ea4b..ceba3574cd17ab6c997029b431e9ffb635161cc9 100644 GIT binary patch delta 14 VcmaFa%QUxzX@f%Q<~NytWdJio2J!#^ delta 355 zcmbQ+!t}D2X@f$lcxZ@%eqwQPYDuwvaY72cdj;N`hf}H)FcjMhLRMVVLBO)QSR2U$XRztGRDp1zjr0w zn~ak*V2VTYAA|8;NB--W&Xm;DfS58&0o0RA}G z-QWA`CmLo({vI=_pFV8=`lw-={8Xe8N8?nE#yr6-n2DLfc!UW~QRuK}^J$h!y>bRi z=4_!x*-VclewcAd(*>K-Z^k73*C-VUmumI&C>2UGp+`KX1^JkxwA0nf#S$)1jgnNH z(l9+?V)W?2D4H;-8Vy=bLpp{uL%}lMroYFHMk7j?Qt(Ez=uGu84(AQ1TFqnX+DSMq zy$v+MJq}y2Rkh%0?Rk3}VjQzsqRWZ22*8*NmO$B8&9HAfr0PSIq~@stCg8$KPi#uwWnR_!@1)mi1yAaulR>oVdCoiqvk;f|VBBMSZZj(kq8r z!Bzo4znXwHSY&8-kPAExSe7L`vX|d^uTl}vehjD}he3s=HWwe$GwdDiH|=`>qrNbI zwulOfR_(k+SD~n)T|a8kSDmQnsqeeZRezf41#E+uB?;My_tx6Eo7XaGx-iH128gwI zEMQAG-}az;Be*(n$lSz?vCkRTms)}ku+W|ofRBx3IV)|`5Is{nX^hZ)3dV+LyVTP@ zVexCCNL0F&+X|3@%XDstqyH(w%-H`bL1DftV?-p^fc@D*qbkR?@{L?rD3A%hoggs@ z0ce_P1cmY=%92$?3Ma;1=49hE$Zgng=felTg)pb_6GoRSQcY}U76h&h_gJg!Y0FBQ zPti=7ORmW-sE?}<-WmV0MVO%F^gCLB#JD38?cF;P_$eck#TbxCj%IT~j^kb-dXKD$ z=~5FgH;2%2VdbaAUPuyJ3@U4pZe!jJn8OiLU!~fC_5OW2`scUvlXp8J-Ejt5U5;bn zT=%7WXvawU?KJv zu;&kHL9&bjUoPA!{nE7#<#zawFZC7Ot6G z*V(5>HjX{NAKh0z_8Ll98yh^EC1?Awvhn3f`FsOrP@Y~ZaZpKHFHHnr&H)q=RJ*zz zgs{GV@|IFWg{Wce@>T=%GrCaCYI<$<5+JYV4j<$dhHe{)JnxrT>h}sNPEp@Q6j!F1 zCN_7FtnxRLfI%g|FCzxp6l|SoA^oaqVW%q@xq}1 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index b5b79be8b143c5bd9de42be79755e8ad9a77c47d..935324272d7b5f0b35394fa461eb1e821fcd3e0b 100644 GIT binary patch delta 103 zcmdmWn{mTk#tom;nUb?7D;mg8QfFliE=dG(E-D#pey`rD&J5&Dmi3iq0gDzmYC`2V kdMiML4tnW87>UxdP`LnE&B^rvJe%wNg?Tr>i#a0z0NIHq3IG5A delta 79 zcmdmRmvPr^#tom;HwS1msZUM_<(}-~B{BI!IQQgqFBLG~Cy;ZqPT&q6uHcfy%OGy=0@0t^YWS`L{3vq=!x0h5;!>a)8OAq2Dj7^VZWE*%60Oal)L delta 622 zcmaE6waIOR36r*hLPKzh-{iT$IC%~)hdPQ0KxKKQIXMdU3I+;RD27j-$SbUgVg$%cB*iFllb7** zwbDT`z*Ys>CLL7g*s7pt-#nXNk!A88K~9vQnmk`XQVH1*^;iX4%t)AAFDQqqk5Nz* zWEeM+9h2t@NGl`pJyQ^dAq!8g6O>2M_g_GK^B&=4%mE50zOz!uO)LmY%qdOvPYbCm sNVQ4I&(E>b$STgy0|o<1AggOak_jlOAXzoJQB;{1i4ROQ5#oGo076>YKL7v# diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 61e33ef4e07281117e1f65215ccd527e0f502d2b..561a42cc852cfa7375c2bc5a0ca475bde8a7d4e7 100644 GIT binary patch delta 171 zcmeyDyDejbJmi-(uj>W~PCBY?$$=N}v#Rd6!#i=eO`SuFMAd$_Tyw_Mn@=G#O zi}b((3bwWonaK|Yl{fzps$~%Y3FVdMD delta 665 zcmdm%@h^9SJQa(#rvO%I%>~q&nO9;D;et&Aiv^byW#*+rh2iE&@m^z@ zTp=VK3)ZXy(WI%MrUrIEeo01Z5yS;xgA{CSk&MNn3T7@`Aw*zufuQPUb)j08$-QFY za&V*aN^^1)>=g_YtdLBdyiizp@_aE?C1kVERYHxOFD$zGw3rdI2_ocdRS;~bcVGrV zrC{96za?L=PkyVwIhjjIN;or3K?4!qU?)!gs3<21W~s+2*doOSME0Sa92df_$&N~* zVyK3|ECQJ$3*(>}1reTnUrvD+Y!*z}W=ADs&dKaXl9SJAu}_|=E#!%?%1R+Ou^=ol zr!>_+Eu^v_)dm>db{bj5`FT1DMd}E3>Y9*b1WHOshEHzPQ07D8d#0#wo}-<~f$W*T G#+Cq03g@Q) diff --git a/mobile/openapi/lib/model/asset_stack_response_dto.dart b/mobile/openapi/lib/model/asset_stack_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..89d30f7810682b57c3d70879621628d1501b2ac1 GIT binary patch literal 3415 zcmbVOZExE)5dQ98aRGu_!BlzMry{Mq28}bMYhoaA3k-%KFcNLEl|_xDY8a{i`|e1+ z+De)&KxB!$JKo*%-0?=E!Ds{*pJ&t8e@<>EcNeqCC0t#9n)q-zh1=;2Zl;&lSAUJ9dsr$muA@|DD-<(pmPK!V zjp8*^dN9D_49F$OoM$W-0sg-o4C0h&4HsH-b8lGu?T+glkK~s|!s7s7gju-brcjv% z1{vSNyjOSu!a5rB0JVBWJhBH+WdY1u=m1aW=7TISnLWEA>08Z}*f6!Thg@5U{TYS= z=z@X9pk2B*N@Ob#N&Bm7!0x=4-?>tlWHj?2WlCvM%t!@c=uIEK@)(n6)?A%II7V{> zM$A%=g7B-IA3xk1h^JEe>XnVsT583PhqN4rj+97f7oVNgZU`jNT{#Mha#qEjYc&+PBvw!4RiI9=*#j^<> zT5t~;Id7Z@Pn&livlE^oR}OLzo_3vB4DI1JEPvA3Cf!J8_MS42a}N z&C#*ZM2fUEy5Rp*yDQ$Zgn^O+d;2V7B?xQwlt6qEn5Nz&j3LFnE2KsT5f|~JZJaA5 zx9lOs1_Mi$YVPkVNE{%F>moH&R{bZ;7tTupmh2L)coA69X1`*CV@lXhwSXvZ0B8VV?2N#cmu(sY@wGTzC1 zsbwU^=2b~Mhw9B*u_axC(r#P*nIkgxLR0zc5`Vrw@n97TvfXY(ty4M*HuV_j|=OC&{}JO6>b65bdg?&F=cb7 z_>J$V!&`mFQ}W;$oKdwdFN_^Osejs3NjFw(g<0bg6&B5By?DHKvZz}2XS}dbho)6B z+4Rtx*5yXbdZNh2q(%F^eDUFBrHnS(OnUCT|HG#jQspJID|>gVP5hKq7+e?gUSI-( ztxjT#Tz*~2O-=hX;~Zf+4FS(<5oH9kHLp2Q&&)_ajpGGF)C;7`1ILkvYdn_fgTcr9 s=`GFQWy`QvZ#{7TMY{DgGk&x=X^$@MiW=hOQ@$NsB(#c?4>qNL0Ucgja{vGU literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 30dc89a47ca4514ff197be78884822cb5e0aea83..3a9b61d81c1b61ecb99d7cb36b4ad7c027bc5caa 100644 GIT binary patch delta 245 zcmZ3S+gY^Xoghm|VsiH6L?$`bVlXRESPIH&6p?|l6on)q$^yAy${M+0$|l0aHu6es zek&Nq=>{?=AhjqnKgGEyHL)aBhf4tnz~)1QgHjVyFa$#jQc%=xHkW=c2C?^}8q6sl tlx3lgGBlEA23j;Z-%u8!s6ZE{NKjdp1!UXghlX+xxdv6)&8kLqEC5~$Q#}9x delta 30 mcmeAyTAaJ#o#13UVZq52B4V2*h0QoOuavnhwz1i%0g~s>EiHPZ7Xfu(H)}LiM>s$ z+NDN*ua&{=rP$(UB@BK$X)HQ7_W1Tpm)42Wr7FjOYNe#%?e=J}LY2~{g)Y}<<~E{C zU;UnD8)58VfZ-hI73fNqqEZR|pAQCEA*_WPC$ewvj1*2@I1P6LfFYLuNxIsU76g=X z2lKww0u-l~$pp>&?;`R5;5N!8=wG_4+`<^R7?>|&e0zU}(R~=M9@q|+GSgdWajS&N zsI4%2wECc|`|HLTRjw!A5FPUVH~NP(2EjqArPc+NXN1!R#^Y$V{3JbztC+HX;&84iS-)m7Qw@;8qQP_z6w)MqrV!pC&W~y|smJT+; zZr^3npbC*&AtI|i1QxaKfop35kv1KdfuNS|3i5tE5HFA^K3 z6V?HG__9foiB|UqULsFE$+E$)&vMl80IeWLM>xUgS2%mbvY^&E{Jn;VDDD#|4#sEQ zq$bf>;xu(KeKL^ci^Rjc`vhLUHV)G2Mg67xw27 zB^>KY)&spbs)^@zCWh>)7on8@elxqa zu?=l<7b$?(^Zqh zB;O}Yneg}Ew>Ld{6st@s9xbJcmLlaND6&HHB;qOG@XYwtv)JTPnUfkU-LjpI^1?)l ze=USU^^9%sGiM6F4ObeCD}7o$mYFszGm%Kpp;!v8thzqwEEO5oQ7W?)ikUUbqBp-r z@tP^!>!CXXatSi$8Oue0|8IM}IAvPHgJJQPd#-cz#xIS8ryjr%3vka(p)w5&GQNd* zS8f5qh8Oby6}oRdvI1b%LI>!D8x0}YgkZk#?YpWZPDmuDPIDzTOzken!nKvy4={3o z4?>%F_P7!H7@R9B@nDq5Rv?n*Q`??>^Im@AN@0o7>Z6n?6-hB86@;NTef-R0ES*_% zHGpu8<_Lf`S8u+5cfUZKDCnzKHs2`WJfD0mSSrjeKt8Zkd)9JUA-@cN0Hua| zDH4nX9k);0nUF;L*b0kbBtVgHGE3`X8)nV~<&)1}B*3ZL-^X?i66w0~=J1uHREeHTZ6fx-=jcZ5W85Fi#( zKhzf5ksbuw=T1r4Mzp@^U{C!2PYntzOTf)34FX&78%qnWVX5S%v^BR!*wafC=u^|r zv2F_=vLeNnApUQKwyoYEOs@D6r;Y7vRm9jdI-+)pv|-pK5stXEOo%}4;Al+zVPmjR zl+d`?DqJue&qhX#g#lIP04>C`YuKxn++j4h>l1*y8@aLc-yoxGqxTdZ&Gz zjOPm%8$n4X2}ueA$C;&cAoQIylTB`Rl}$t!$FEnKmRr1Otmxr=g}>uQc*LuKsC|Rp zJPf=njzy^E(^+V#-84xIp)>-PB#w0rO;_5g#H~V?T1HY@URA0QRKKhh+qESM&53jj z1~RDTS`&*ZR96<1YL2QSGu6y$mL2{6wCWa~(4q39X2Qu+u0|uMx0nb!2lpk~i`;F| zLJ&{YuEH>&C&6`<4WZ&PdLBz_D6n!3WDYapB&JxXxl{bYcQmoBzT+f0{|T_DS~n1; zjgK@KZIz^347S3o@re3|_Ny)mubmibl`|Lb9TcZUldK));%(!OA{OD?=(N$86zJ7S zZus!BK1CZ%*8ITL|KZLHY4H+@mGihU1D<6N=G6wdTfg=QZxG3^+w{<|e$6gNj#js$ zs|G2CFkAB)|9YiGs%cye7{Xj2H6A&xDqN#mY6Av057Rr^nzg+q%e}Z#{+o2`EoA&; Y%TcWy?&_dxr2N&n66h4S6`VHz0>a`r5C8xG literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/stack_update_dto.dart b/mobile/openapi/lib/model/stack_update_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..0e9712721048aad0db30777e0c14768d0600fbe1 GIT binary patch literal 3295 zcmbVPVNV-55dEHCF;u4#q$Tw3Q$;VKsKBYcLQaLAbc)c*>^j8l#@@5NL=nw@zc==7 z*bOP5tJFZ&>hy%pK7Kw;=wwV6<10EJpL{&~ zX9r;%`92rc4}Tl{`nJbk)vY#Grc-0nsVd|lRl0IAPh}zJQv0O&uI8n&e(MfVEX2~K zW#v;V|E-k8b}i=kEQQ5SFRepz>$Y#tjCNjVpQ;=M)l^9vw!5vx3Z3!`rl=4V2u zZ{MWZOjy_Jp`0f(B~wZ*N;Sa$cfDR#26p{l`4P!fFqNLxuQBe-%z>^8Dj3#EQPM#a-I5pbM ziYk{lO)jURDm>kZqLS3FoYJ=;$*X$h@YTA5y@Y0FA@8J3LddPu(gLTvPVY=*(L!dB zd0shBQ&ALj13U>xqHoR(0PB{?FUtTJRf>%o-&tuq@2GI9U4j2kxKb8R*qsL4j zF+MN7MaCl>qm6#R{k_3qEcPMn#+ZT?ffE@^!(o4X^G#;Z2_Ut5G#pXXT_%b?S4oGf+QrntPpJ}Nxdk9}tee^aB>V-IGqbwT8cRzhCbLxX(rTdrXp0 zzQpLOY=pd@oYBY{2W6_XDCip=(U2ZR->=?Ldzr?A1`pti~&aF??5Q|Dn4Sqkr+nVe-4A8hFO zD0C2Ls%eXzH83+9(LgW)Xs3BjFrOBiA!LvV=O6LfkFw)osWI&8s~_Oy|NvO73`!=JnFN65_&}Z9-A)zLp}bn3ZHe!hRr}*4a8>^x^2G`MpWS8893=65-Z*Z z8@aV+!LAIM!2_L4Rt+Q(5Gx&lG(?Jq8{`gHUEw5Xx`u_Jc_8)CQ1Nm+!T@$1sc-F!pf=!kAg*_L z!-wiOFdb6!TgcMJzik;^NsO5Ktt>PrkB}g$?IasX+J@2qj+#&69pZ+OkDsX-!}>6N z&Fv<|1q1gezAr_K?r?{vcG<8u;BQL0<}3_(JkN71hBSi^)#BtCHUZKkZu~Bn?2ci7 z)FPLDf>La4c^#9&t`smb@eR6AeuhUpHGJ#pGO! zuzE1+ozV$;;DxQ4Na$sgiIQ!#Y%TPEH1k3^yhK{HgSDpqS>@nVU3$CN4VaoWY%KvL z>%n{&(PXWvD2mQ}qfko>2e`q?HSf)xpCB=Z*RKG2iF*x3`-2 Z@X~_a#6KARQ!RQ`wxDvoI{sZ{NF>?R_ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/update_stack_parent_dto.dart b/mobile/openapi/lib/model/update_stack_parent_dto.dart deleted file mode 100644 index 4247c2e29fe733e9ca8760e8313546a6575d2db8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3204 zcmbVOZExE)5dQ98aRG{2K~;I%ry`xa7K<~aYvQ7D2MmTGFcM|4l|_xDY8a{i`|e0d zrW-ZMR-j2l-W#9ixg#f&(PRQ|KP~1j|Cn9RKE7Sd&f((n{Vas@Ib6>da5X=_y!iVB z&B*di#*~SFiGF@HqEE4|q~b{~Rg#O6pFvYKnr8_w`G!{}?7qdOmdXrzuyV_GI;k6z zDE?0)6uMVzgFkDg@VDVgV{oO1-BVd2l9hTj;Le)+%@COS6~?nVF;1mPn$jjA*- zsQ4C^L$)grw#<}A=rm*zP&9xJ0x*Tp5yo@rF0*i8Tb7x>y+6fz0d9;E)tYMQHvMJK z$1cB-U%65U9(vwNS<>+pGg3hu2lLy{JVj7U!PP0mQ*a=stPH5Wu-p0e&HV-92T(nK zZp*iU{8M2Pzfd@`^GYQ<@AF>eqB5}N1`4K&-(|+JR-3SwC@HhzDc72*9VrVrEVvfh zngt$MQ5WMBUbsy!@nQ2`2`%hKx<3Mo*LaBPrI#&!=6xWZ2S(#(-p6We^Ifs}R~J5n zY=>PU(kU4EPRk1OIR)jSVkLaWC5Yjk_1*rpJrr1rr5bzb$1C*sUU%P_do>-*y+voP zY)9A>w6G^x{^w2sR%PG@hX!FZd}U?BHRMWe+IUO5gw2(s!I(O5i4d(qsG1U$OtRey zZF|mBLalg??QbWCYEsm?PN)kK93)1h3C^5CW~2pd zU{T#Vz!5>#Bube!_oxb7JG-es6K1fE0Lyg$JSNXz!|J~Xu6e%L)x3>84M-Lt9J79U z;${m)@tAxxhG#oI#K58{(BA%RbwF(NI%qL{FYw4i-7$p{w=R(6BXC{icXmXrmE5x1 z61yC7R%#yZJ9HhkitDB{B>%w#%ayBvpLj=nYOLqbtCt_LV6yGGM8S9Ya5A7@RWg zo8$~|$h+r5f;TumROmtC#{ChSxTtgXk=Bm5#@OXv87 z?`Q^Ff2UC@-~p1+wQnpuI)2g+v`9&JcW;F$aEW?~9ydd5ymY4M+0IzJQqYB7dKB?6 z2d}*Ahg3D(UQ=?S?Ln=C@U$Mr7`}=gNcVrV^hB9FMRM)@_2$E)io&xU6F$UBM3nUq zdcq3(7Ctm{*sINn1n+D8B*S$RBk!oHK{QzwFZNCH}h+H ly0){z-ktqp^-uEc9~;v<3(3*T!rd@q|H_~xe2m}(`WKGx78?Kn diff --git a/mobile/test/fixtures/asset.stub.dart b/mobile/test/fixtures/asset.stub.dart index b173dd2ac5..26108d63b2 100644 --- a/mobile/test/fixtures/asset.stub.dart +++ b/mobile/test/fixtures/asset.stub.dart @@ -17,7 +17,6 @@ final class AssetStub { isFavorite: true, isArchived: false, isTrashed: false, - stackCount: 0, ); static final image2 = Asset( @@ -34,6 +33,5 @@ final class AssetStub { isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, ); } diff --git a/mobile/test/modules/extensions/asset_extensions_test.dart b/mobile/test/modules/extensions/asset_extensions_test.dart index b90879acc7..d2b9b93d62 100644 --- a/mobile/test/modules/extensions/asset_extensions_test.dart +++ b/mobile/test/modules/extensions/asset_extensions_test.dart @@ -34,7 +34,6 @@ Asset makeAsset({ isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, exifInfo: exifInfo, ); } diff --git a/mobile/test/modules/home/asset_grid_data_structure_test.dart b/mobile/test/modules/home/asset_grid_data_structure_test.dart index f12b9b2190..b4ee851969 100644 --- a/mobile/test/modules/home/asset_grid_data_structure_test.dart +++ b/mobile/test/modules/home/asset_grid_data_structure_test.dart @@ -25,7 +25,6 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, ), ); } diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 24f0c443ba..07437289be 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -32,7 +32,6 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0d0793c263..a9b08fc400 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1689,41 +1689,6 @@ ] } }, - "/assets/stack/parent": { - "put": { - "operationId": "updateStackParent", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateStackParentDto" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Assets" - ] - } - }, "/assets/statistics": { "get": { "operationId": "getAssetStatistics", @@ -5655,6 +5620,248 @@ ] } }, + "/stacks": { + "delete": { + "operationId": "deleteStacks", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkIdsDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + }, + "get": { + "operationId": "searchStacks", + "parameters": [ + { + "name": "primaryAssetId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/StackResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + }, + "post": { + "operationId": "createStack", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + } + }, + "/stacks/{id}": { + "delete": { + "operationId": "deleteStack", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + }, + "get": { + "operationId": "getStack", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + }, + "put": { + "operationId": "updateStack", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StackResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Stacks" + ] + } + }, "/sync/delta-sync": { "post": { "operationId": "getDeltaSync", @@ -7570,13 +7777,6 @@ "maximum": 5, "minimum": 0, "type": "number" - }, - "removeParent": { - "type": "boolean" - }, - "stackParentId": { - "format": "uuid", - "type": "string" } }, "required": [ @@ -8117,18 +8317,12 @@ "$ref": "#/components/schemas/SmartInfoResponseDto" }, "stack": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" - }, - "stackCount": { - "nullable": true, - "type": "integer" - }, - "stackParentId": { - "nullable": true, - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/AssetStackResponseDto" + } + ], + "nullable": true }, "tags": { "items": { @@ -8172,13 +8366,31 @@ "originalPath", "ownerId", "resized", - "stackCount", "thumbhash", "type", "updatedAt" ], "type": "object" }, + "AssetStackResponseDto": { + "properties": { + "assetCount": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "primaryAssetId": { + "type": "string" + } + }, + "required": [ + "assetCount", + "id", + "primaryAssetId" + ], + "type": "object" + }, "AssetStatsResponseDto": { "properties": { "images": { @@ -9806,6 +10018,10 @@ "sharedLink.read", "sharedLink.update", "sharedLink.delete", + "stack.create", + "stack.read", + "stack.update", + "stack.delete", "systemConfig.read", "systemConfig.update", "systemMetadata.read", @@ -10882,6 +11098,53 @@ ], "type": "object" }, + "StackCreateDto": { + "properties": { + "assetIds": { + "description": "first asset becomes the primary", + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "assetIds" + ], + "type": "object" + }, + "StackResponseDto": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "primaryAssetId": { + "type": "string" + } + }, + "required": [ + "assets", + "id", + "primaryAssetId" + ], + "type": "object" + }, + "StackUpdateDto": { + "properties": { + "primaryAssetId": { + "format": "uuid", + "type": "string" + } + }, + "type": "object" + }, "SystemConfigDto": { "properties": { "ffmpeg": { @@ -11735,23 +11998,6 @@ ], "type": "object" }, - "UpdateStackParentDto": { - "properties": { - "newParentId": { - "format": "uuid", - "type": "string" - }, - "oldParentId": { - "format": "uuid", - "type": "string" - } - }, - "required": [ - "newParentId", - "oldParentId" - ], - "type": "object" - }, "UpdateTagDto": { "properties": { "name": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 89e0360368..8b503821f7 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -192,6 +192,11 @@ export type SmartInfoResponseDto = { objects?: string[] | null; tags?: string[] | null; }; +export type AssetStackResponseDto = { + assetCount: number; + id: string; + primaryAssetId: string; +}; export type TagResponseDto = { id: string; name: string; @@ -226,9 +231,7 @@ export type AssetResponseDto = { people?: PersonWithFacesResponseDto[]; resized: boolean; smartInfo?: SmartInfoResponseDto; - stack?: AssetResponseDto[]; - stackCount: number | null; - stackParentId?: string | null; + stack?: (AssetStackResponseDto) | null; tags?: TagResponseDto[]; thumbhash: string | null; "type": AssetTypeEnum; @@ -344,8 +347,6 @@ export type AssetBulkUpdateDto = { latitude?: number; longitude?: number; rating?: number; - removeParent?: boolean; - stackParentId?: string; }; export type AssetBulkUploadCheckItem = { /** base64 or hex encoded sha1 hash */ @@ -379,10 +380,6 @@ export type MemoryLaneResponseDto = { assets: AssetResponseDto[]; yearsAgo: number; }; -export type UpdateStackParentDto = { - newParentId: string; - oldParentId: string; -}; export type AssetStatsResponseDto = { images: number; total: number; @@ -973,6 +970,18 @@ export type AssetIdsResponseDto = { error?: Error2; success: boolean; }; +export type StackResponseDto = { + assets: AssetResponseDto[]; + id: string; + primaryAssetId: string; +}; +export type StackCreateDto = { + /** first asset becomes the primary */ + assetIds: string[]; +}; +export type StackUpdateDto = { + primaryAssetId?: string; +}; export type AssetDeltaSyncDto = { updatedAfter: string; userIds: string[]; @@ -1632,15 +1641,6 @@ export function getRandom({ count }: { ...opts })); } -export function updateStackParent({ updateStackParentDto }: { - updateStackParentDto: UpdateStackParentDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/assets/stack/parent", oazapfts.json({ - ...opts, - method: "PUT", - body: updateStackParentDto - }))); -} export function getAssetStatistics({ isArchived, isFavorite, isTrashed }: { isArchived?: boolean; isFavorite?: boolean; @@ -2706,6 +2706,70 @@ export function addSharedLinkAssets({ id, key, assetIdsDto }: { body: assetIdsDto }))); } +export function deleteStacks({ bulkIdsDto }: { + bulkIdsDto: BulkIdsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/stacks", oazapfts.json({ + ...opts, + method: "DELETE", + body: bulkIdsDto + }))); +} +export function searchStacks({ primaryAssetId }: { + primaryAssetId?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: StackResponseDto[]; + }>(`/stacks${QS.query(QS.explode({ + primaryAssetId + }))}`, { + ...opts + })); +} +export function createStack({ stackCreateDto }: { + stackCreateDto: StackCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: StackResponseDto; + }>("/stacks", oazapfts.json({ + ...opts, + method: "POST", + body: stackCreateDto + }))); +} +export function deleteStack({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/stacks/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +export function getStack({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: StackResponseDto; + }>(`/stacks/${encodeURIComponent(id)}`, { + ...opts + })); +} +export function updateStack({ id, stackUpdateDto }: { + id: string; + stackUpdateDto: StackUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: StackResponseDto; + }>(`/stacks/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: stackUpdateDto + }))); +} export function getDeltaSync({ assetDeltaSyncDto }: { assetDeltaSyncDto: AssetDeltaSyncDto; }, opts?: Oazapfts.RequestOpts) { @@ -3187,6 +3251,10 @@ export enum Permission { SharedLinkRead = "sharedLink.read", SharedLinkUpdate = "sharedLink.update", SharedLinkDelete = "sharedLink.delete", + StackCreate = "stack.create", + StackRead = "stack.read", + StackUpdate = "stack.update", + StackDelete = "stack.delete", SystemConfigRead = "systemConfig.read", SystemConfigUpdate = "systemConfig.update", SystemMetadataRead = "systemMetadata.read", diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 8c70bed166..f275aa7242 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -13,7 +13,6 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; -import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Route } from 'src/middleware/file-upload.interceptor'; import { AssetService } from 'src/services/asset.service'; @@ -72,13 +71,6 @@ export class AssetController { return this.service.deleteAll(auth, dto); } - @Put('stack/parent') - @HttpCode(HttpStatus.OK) - @Authenticated() - updateStackParent(@Auth() auth: AuthDto, @Body() dto: UpdateStackParentDto): Promise { - return this.service.updateStackParent(auth, dto); - } - @Get(':id') @Authenticated({ sharedLink: true }) getAssetInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 9675cf6d3b..3a832c1a1b 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -23,6 +23,7 @@ import { ServerInfoController } from 'src/controllers/server-info.controller'; import { ServerController } from 'src/controllers/server.controller'; import { SessionController } from 'src/controllers/session.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller'; +import { StackController } from 'src/controllers/stack.controller'; import { SyncController } from 'src/controllers/sync.controller'; import { SystemConfigController } from 'src/controllers/system-config.controller'; import { SystemMetadataController } from 'src/controllers/system-metadata.controller'; @@ -58,6 +59,7 @@ export const controllers = [ ServerInfoController, SessionController, SharedLinkController, + StackController, SyncController, SystemConfigController, SystemMetadataController, diff --git a/server/src/controllers/stack.controller.ts b/server/src/controllers/stack.controller.ts new file mode 100644 index 0000000000..184fa96b38 --- /dev/null +++ b/server/src/controllers/stack.controller.ts @@ -0,0 +1,57 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto } from 'src/dtos/stack.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { StackService } from 'src/services/stack.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Stacks') +@Controller('stacks') +export class StackController { + constructor(private service: StackService) {} + + @Get() + @Authenticated({ permission: Permission.STACK_READ }) + searchStacks(@Auth() auth: AuthDto, @Query() query: StackSearchDto): Promise { + return this.service.search(auth, query); + } + + @Post() + @Authenticated({ permission: Permission.STACK_CREATE }) + createStack(@Auth() auth: AuthDto, @Body() dto: StackCreateDto): Promise { + return this.service.create(auth, dto); + } + + @Delete() + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.STACK_DELETE }) + deleteStacks(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { + return this.service.deleteAll(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.STACK_READ }) + getStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ permission: Permission.STACK_UPDATE }) + updateStack( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: StackUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.STACK_DELETE }) + deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } +} diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts index b8ba88b59d..f0050b3947 100644 --- a/server/src/cores/access.core.ts +++ b/server/src/cores/access.core.ts @@ -292,6 +292,18 @@ export class AccessCore { return await this.repository.partner.checkUpdateAccess(auth.user.id, ids); } + case Permission.STACK_READ: { + return this.repository.stack.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.STACK_UPDATE: { + return this.repository.stack.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.STACK_DELETE: { + return this.repository.stack.checkOwnerAccess(auth.user.id, ids); + } + default: { return new Set(); } diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 4238fd3490..6ed1125253 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -52,13 +52,19 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { unassignedFaces?: AssetFaceWithoutPersonResponseDto[]; /**base64 encoded sha1 hash */ checksum!: string; - stackParentId?: string | null; - stack?: AssetResponseDto[]; - @ApiProperty({ type: 'integer' }) - stackCount!: number | null; + stack?: AssetStackResponseDto | null; duplicateId?: string | null; } +export class AssetStackResponseDto { + id!: string; + + primaryAssetId!: string; + + @ApiProperty({ type: 'integer' }) + assetCount!: number; +} + export type AssetMapOptions = { stripMetadata?: boolean; withStack?: boolean; @@ -83,6 +89,18 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] return result; }; +const mapStack = (entity: AssetEntity) => { + if (!entity.stack) { + return null; + } + + return { + id: entity.stack.id, + primaryAssetId: entity.stack.primaryAssetId, + assetCount: entity.stack.assetCount ?? entity.stack.assets.length, + }; +}; + export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; @@ -129,13 +147,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As people: peopleWithFaces(entity.faces), unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), checksum: entity.checksum.toString('base64'), - stackParentId: withStack ? entity.stack?.primaryAssetId : undefined, - stack: withStack - ? entity.stack?.assets - ?.filter((a) => a.id !== entity.stack?.primaryAssetId) - ?.map((a) => mapAsset(a, { stripMetadata, auth: options.auth })) - : undefined, - stackCount: entity.stack?.assetCount ?? entity.stack?.assets?.length ?? null, + stack: withStack ? mapStack(entity) : undefined, isOffline: entity.isOffline, hasMetadata: true, duplicateId: entity.duplicateId, diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 9bc007543a..5a2fdb5120 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -60,12 +60,6 @@ export class AssetBulkUpdateDto extends UpdateAssetBase { @ValidateUUID({ each: true }) ids!: string[]; - @ValidateUUID({ optional: true }) - stackParentId?: string; - - @ValidateBoolean({ optional: true }) - removeParent?: boolean; - @Optional() duplicateId?: string | null; } diff --git a/server/src/dtos/stack.dto.ts b/server/src/dtos/stack.dto.ts index 3ff04ee5ed..3b867b02fe 100644 --- a/server/src/dtos/stack.dto.ts +++ b/server/src/dtos/stack.dto.ts @@ -1,9 +1,38 @@ +import { ArrayMinSize } from 'class-validator'; +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { StackEntity } from 'src/entities/stack.entity'; import { ValidateUUID } from 'src/validation'; -export class UpdateStackParentDto { - @ValidateUUID() - oldParentId!: string; - - @ValidateUUID() - newParentId!: string; +export class StackCreateDto { + /** first asset becomes the primary */ + @ValidateUUID({ each: true }) + @ArrayMinSize(2) + assetIds!: string[]; } + +export class StackSearchDto { + primaryAssetId?: string; +} + +export class StackUpdateDto { + @ValidateUUID({ optional: true }) + primaryAssetId?: string; +} + +export class StackResponseDto { + id!: string; + primaryAssetId!: string; + assets!: AssetResponseDto[]; +} + +export const mapStack = (stack: StackEntity, { auth }: { auth?: AuthDto }) => { + const primary = stack.assets.filter((asset) => asset.id === stack.primaryAssetId); + const others = stack.assets.filter((asset) => asset.id !== stack.primaryAssetId); + + return { + id: stack.id, + primaryAssetId: stack.primaryAssetId, + assets: [...primary, ...others].map((asset) => mapAsset(asset, { auth })), + }; +}; diff --git a/server/src/enum.ts b/server/src/enum.ts index da4b2d76fc..4a81d54218 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -107,6 +107,11 @@ export enum Permission { SHARED_LINK_UPDATE = 'sharedLink.update', SHARED_LINK_DELETE = 'sharedLink.delete', + STACK_CREATE = 'stack.create', + STACK_READ = 'stack.read', + STACK_UPDATE = 'stack.update', + STACK_DELETE = 'stack.delete', + SYSTEM_CONFIG_READ = 'systemConfig.read', SYSTEM_CONFIG_UPDATE = 'systemConfig.update', diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts index cf5ebbd005..2dcf9d6b94 100644 --- a/server/src/interfaces/access.interface.ts +++ b/server/src/interfaces/access.interface.ts @@ -42,4 +42,8 @@ export interface IAccessRepository { partner: { checkUpdateAccess(userId: string, partnerIds: Set): Promise>; }; + + stack: { + checkOwnerAccess(userId: string, stackIds: Set): Promise>; + }; } diff --git a/server/src/interfaces/stack.interface.ts b/server/src/interfaces/stack.interface.ts index 0e6baf0a34..378f63fd95 100644 --- a/server/src/interfaces/stack.interface.ts +++ b/server/src/interfaces/stack.interface.ts @@ -2,9 +2,16 @@ import { StackEntity } from 'src/entities/stack.entity'; export const IStackRepository = 'IStackRepository'; +export interface StackSearch { + ownerId: string; + primaryAssetId?: string; +} + export interface IStackRepository { - create(stack: Partial & { ownerId: string }): Promise; + search(query: StackSearch): Promise; + create(stack: { ownerId: string; assetIds: string[] }): Promise; update(stack: Pick & Partial): Promise; delete(id: string): Promise; + deleteAll(ids: string[]): Promise; getById(id: string): Promise; } diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index ffe4b6413f..48a93f546b 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -248,6 +248,17 @@ WHERE "partner"."sharedById" IN ($1) AND "partner"."sharedWithId" = $2 +-- AccessRepository.stack.checkOwnerAccess +SELECT + "StackEntity"."id" AS "StackEntity_id" +FROM + "asset_stack" "StackEntity" +WHERE + ( + ("StackEntity"."id" IN ($1)) + AND ("StackEntity"."ownerId" = $2) + ) + -- AccessRepository.timeline.checkPartnerAccess SELECT "partner"."sharedById" AS "partner_sharedById", diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 438424ab78..6dd6d47a46 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -11,6 +11,7 @@ import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { StackEntity } from 'src/entities/stack.entity'; import { AlbumUserRole } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -20,10 +21,11 @@ type IActivityAccess = IAccessRepository['activity']; type IAlbumAccess = IAccessRepository['album']; type IAssetAccess = IAccessRepository['asset']; type IAuthDeviceAccess = IAccessRepository['authDevice']; -type ITimelineAccess = IAccessRepository['timeline']; type IMemoryAccess = IAccessRepository['memory']; type IPersonAccess = IAccessRepository['person']; type IPartnerAccess = IAccessRepository['partner']; +type IStackAccess = IAccessRepository['stack']; +type ITimelineAccess = IAccessRepository['timeline']; @Instrumentation() @Injectable() @@ -313,6 +315,28 @@ class AuthDeviceAccess implements IAuthDeviceAccess { } } +class StackAccess implements IStackAccess { + constructor(private stackRepository: Repository) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, stackIds: Set): Promise> { + if (stackIds.size === 0) { + return new Set(); + } + + return this.stackRepository + .find({ + select: { id: true }, + where: { + id: In([...stackIds]), + ownerId: userId, + }, + }) + .then((stacks) => new Set(stacks.map((stack) => stack.id))); + } +} + class TimelineAccess implements ITimelineAccess { constructor(private partnerRepository: Repository) {} @@ -428,6 +452,7 @@ export class AccessRepository implements IAccessRepository { memory: IMemoryAccess; person: IPersonAccess; partner: IPartnerAccess; + stack: IStackAccess; timeline: ITimelineAccess; constructor( @@ -441,6 +466,7 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(AssetFaceEntity) assetFaceRepository: Repository, @InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository, @InjectRepository(SessionEntity) sessionRepository: Repository, + @InjectRepository(StackEntity) stackRepository: Repository, ) { this.activity = new ActivityAccess(activityRepository, albumRepository); this.album = new AlbumAccess(albumRepository, sharedLinkRepository); @@ -449,6 +475,7 @@ export class AccessRepository implements IAccessRepository { this.memory = new MemoryAccess(memoryRepository); this.person = new PersonAccess(assetFaceRepository, personRepository); this.partner = new PartnerAccess(partnerRepository); + this.stack = new StackAccess(stackRepository); this.timeline = new TimelineAccess(partnerRepository); } } diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index 46cc14e713..f23a1c9a9c 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -1,21 +1,120 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { AssetEntity } from 'src/entities/asset.entity'; import { StackEntity } from 'src/entities/stack.entity'; -import { IStackRepository } from 'src/interfaces/stack.interface'; +import { IStackRepository, StackSearch } from 'src/interfaces/stack.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Repository } from 'typeorm'; +import { DataSource, In, Repository } from 'typeorm'; @Instrumentation() @Injectable() export class StackRepository implements IStackRepository { - constructor(@InjectRepository(StackEntity) private repository: Repository) {} + constructor( + @InjectDataSource() private dataSource: DataSource, + @InjectRepository(StackEntity) private repository: Repository, + ) {} - create(entity: Partial) { - return this.save(entity); + search(query: StackSearch): Promise { + return this.repository.find({ + where: { + ownerId: query.ownerId, + primaryAssetId: query.primaryAssetId, + }, + relations: { + assets: { + exifInfo: true, + }, + }, + }); + } + + async create(entity: { ownerId: string; assetIds: string[] }): Promise { + return this.dataSource.manager.transaction(async (manager) => { + const stackRepository = manager.getRepository(StackEntity); + + const stacks = await stackRepository.find({ + where: { + ownerId: entity.ownerId, + primaryAssetId: In(entity.assetIds), + }, + select: { + id: true, + assets: { + id: true, + }, + }, + relations: { + assets: { + exifInfo: true, + }, + }, + }); + + const assetIds = new Set(entity.assetIds); + + // children + for (const stack of stacks) { + for (const asset of stack.assets) { + assetIds.add(asset.id); + } + } + + if (stacks.length > 0) { + await stackRepository.delete({ id: In(stacks.map((stack) => stack.id)) }); + } + + const { id } = await stackRepository.save({ + ownerId: entity.ownerId, + primaryAssetId: entity.assetIds[0], + assets: [...assetIds].map((id) => ({ id }) as AssetEntity), + }); + + return stackRepository.findOneOrFail({ + where: { + id, + }, + relations: { + assets: { + exifInfo: true, + }, + }, + }); + }); } async delete(id: string): Promise { + const stack = await this.getById(id); + if (!stack) { + return; + } + + const assetIds = stack.assets.map(({ id }) => id); + await this.repository.delete(id); + + // Update assets updatedAt + await this.dataSource.manager.update(AssetEntity, assetIds, { + updatedAt: new Date(), + }); + } + + async deleteAll(ids: string[]): Promise { + const assetIds = []; + for (const id of ids) { + const stack = await this.getById(id); + if (!stack) { + continue; + } + + assetIds.push(...stack.assets.map(({ id }) => id)); + } + + await this.repository.delete(ids); + + // Update assets updatedAt + await this.dataSource.manager.update(AssetEntity, assetIds, { + updatedAt: new Date(), + }); } update(entity: Partial) { @@ -28,8 +127,14 @@ export class StackRepository implements IStackRepository { id, }, relations: { - primaryAsset: true, - assets: true, + assets: { + exifInfo: true, + }, + }, + order: { + assets: { + fileCreatedAt: 'ASC', + }, }, }); } @@ -41,8 +146,14 @@ export class StackRepository implements IStackRepository { id, }, relations: { - primaryAsset: true, - assets: true, + assets: { + exifInfo: true, + }, + }, + order: { + assets: { + fileCreatedAt: 'ASC', + }, }, }); } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 95a80ab4da..f79b2819ff 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -4,7 +4,7 @@ import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetType } from 'src/enum'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; @@ -12,7 +12,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AssetService } from 'src/services/asset.service'; -import { assetStub, stackStub } from 'test/fixtures/asset.stub'; +import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; @@ -253,134 +253,6 @@ describe(AssetService.name, () => { await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); }); - - /// Stack related - - it('should require asset update access for parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - await expect( - sut.updateAll(authStub.user1, { - ids: ['asset-1'], - stackParentId: 'parent', - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should update parent asset updatedAt when children are added', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['parent'])); - mockGetById([{ ...assetStub.image, id: 'parent' }]); - await sut.updateAll(authStub.user1, { - ids: [], - stackParentId: 'parent', - }); - expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { updatedAt: expect.any(Date) }); - }); - - it('should update parent asset when children are removed', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1'])); - assetMock.getByIds.mockResolvedValue([ - { - id: 'child-1', - stackId: 'stack-1', - stack: stackStub('stack-1', [{ id: 'parent' } as AssetEntity, { id: 'child-1' } as AssetEntity]), - } as AssetEntity, - ]); - stackMock.getById.mockResolvedValue(stackStub('stack-1', [{ id: 'parent' } as AssetEntity])); - - await sut.updateAll(authStub.user1, { - ids: ['child-1'], - removeParent: true, - }); - expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['child-1']), { stack: null }); - expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { - updatedAt: expect.any(Date), - }); - expect(stackMock.delete).toHaveBeenCalledWith('stack-1'); - }); - - it('update parentId for new children', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1', 'child-2'])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); - const stack = stackStub('stack-1', [ - { id: 'parent' } as AssetEntity, - { id: 'child-1' } as AssetEntity, - { id: 'child-2' } as AssetEntity, - ]); - assetMock.getById.mockResolvedValue({ - id: 'child-1', - stack, - } as AssetEntity); - - await sut.updateAll(authStub.user1, { - stackParentId: 'parent', - ids: ['child-1', 'child-2'], - }); - - expect(stackMock.update).toHaveBeenCalledWith({ - ...stackStub('stack-1', [ - { id: 'child-1' } as AssetEntity, - { id: 'child-2' } as AssetEntity, - { id: 'parent' } as AssetEntity, - ]), - primaryAsset: undefined, - }); - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2', 'parent'], { updatedAt: expect.any(Date) }); - }); - - it('remove stack for removed children', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1', 'child-2'])); - await sut.updateAll(authStub.user1, { - removeParent: true, - ids: ['child-1', 'child-2'], - }); - - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stack: null }); - }); - - it('merge stacks if new child has children', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); - assetMock.getById.mockResolvedValue({ ...assetStub.image, id: 'parent' }); - assetMock.getByIds.mockResolvedValue([ - { - id: 'child-1', - stackId: 'stack-1', - stack: stackStub('stack-1', [{ id: 'child-1' } as AssetEntity, { id: 'child-2' } as AssetEntity]), - } as AssetEntity, - ]); - stackMock.getById.mockResolvedValue(stackStub('stack-1', [{ id: 'parent' } as AssetEntity])); - - await sut.updateAll(authStub.user1, { - ids: ['child-1'], - stackParentId: 'parent', - }); - - expect(stackMock.delete).toHaveBeenCalledWith('stack-1'); - expect(stackMock.create).toHaveBeenCalledWith({ - assets: [{ id: 'child-1' }, { id: 'parent' }, { id: 'child-1' }, { id: 'child-2' }], - ownerId: 'user-id', - primaryAssetId: 'parent', - }); - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'parent', 'child-1', 'child-2'], { - updatedAt: expect.any(Date), - }); - }); - - it('should send ws asset update event', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); - assetMock.getById.mockResolvedValue(assetStub.image); - - await sut.updateAll(authStub.user1, { - ids: ['asset-1'], - stackParentId: 'parent', - }); - - expect(eventMock.clientSend).toHaveBeenCalledWith(ClientEvent.ASSET_STACK_UPDATE, authStub.user1.user.id, [ - 'asset-1', - 'parent', - ]); - }); }); describe('deleteAll', () => { @@ -530,53 +402,17 @@ describe(AssetService.name, () => { }); }); - describe('updateStackParent', () => { - it('should require asset update access for new parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['old'])); - await expect( - sut.updateStackParent(authStub.user1, { - oldParentId: 'old', - newParentId: 'new', - }), - ).rejects.toBeInstanceOf(BadRequestException); + describe('getUserAssetsByDeviceId', () => { + it('get assets by device id', async () => { + const assets = [assetStub.image, assetStub.image1]; + + assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); + + const deviceId = 'device-id'; + const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); + + expect(result.length).toEqual(2); + expect(result).toEqual(assets.map((asset) => asset.deviceAssetId)); }); - - it('should require asset read access for old parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['new'])); - await expect( - sut.updateStackParent(authStub.user1, { - oldParentId: 'old', - newParentId: 'new', - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('make old parent the child of new parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.image.id])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new'])); - assetMock.getById.mockResolvedValue({ ...assetStub.image, stackId: 'stack-1' }); - - await sut.updateStackParent(authStub.user1, { - oldParentId: assetStub.image.id, - newParentId: 'new', - }); - - expect(stackMock.update).toBeCalledWith({ id: 'stack-1', primaryAssetId: 'new' }); - expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id, 'new', assetStub.image.id], { - updatedAt: expect.any(Date), - }); - }); - }); - - it('get assets by device id', async () => { - const assets = [assetStub.image, assetStub.image1]; - - assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); - - const deviceId = 'device-id'; - const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); - - expect(result.length).toEqual(2); - expect(result).toEqual(assets.map((asset) => asset.deviceAssetId)); }); }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index bbbc2bb407..94a3ba1603 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -20,7 +20,6 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; -import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; @@ -179,68 +178,14 @@ export class AssetService { } async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { - const { ids, removeParent, dateTimeOriginal, latitude, longitude, ...options } = dto; + const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto; await this.access.requirePermission(auth, Permission.ASSET_UPDATE, ids); - // TODO: refactor this logic into separate API calls POST /stack, PUT /stack, etc. - const stackIdsToCheckForDelete: string[] = []; - if (removeParent) { - (options as Partial).stack = null; - const assets = await this.assetRepository.getByIds(ids, { stack: true }); - stackIdsToCheckForDelete.push(...new Set(assets.filter((a) => !!a.stackId).map((a) => a.stackId!))); - // This updates the updatedAt column of the parents to indicate that one of its children is removed - // All the unique parent's -> parent is set to null - await this.assetRepository.updateAll( - assets.filter((a) => !!a.stack?.primaryAssetId).map((a) => a.stack!.primaryAssetId!), - { updatedAt: new Date() }, - ); - } else if (options.stackParentId) { - //Creating new stack if parent doesn't have one already. If it does, then we add to the existing stack - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, options.stackParentId); - const primaryAsset = await this.assetRepository.getById(options.stackParentId, { stack: { assets: true } }); - if (!primaryAsset) { - throw new BadRequestException('Asset not found for given stackParentId'); - } - let stack = primaryAsset.stack; - - ids.push(options.stackParentId); - const assets = await this.assetRepository.getByIds(ids, { stack: { assets: true } }); - stackIdsToCheckForDelete.push( - ...new Set(assets.filter((a) => !!a.stackId && stack?.id !== a.stackId).map((a) => a.stackId!)), - ); - const assetsWithChildren = assets.filter((a) => a.stack && a.stack.assets.length > 0); - ids.push(...assetsWithChildren.flatMap((child) => child.stack!.assets.map((gChild) => gChild.id))); - - if (stack) { - await this.stackRepository.update({ - id: stack.id, - primaryAssetId: primaryAsset.id, - assets: ids.map((id) => ({ id }) as AssetEntity), - }); - } else { - stack = await this.stackRepository.create({ - primaryAssetId: primaryAsset.id, - ownerId: primaryAsset.ownerId, - assets: ids.map((id) => ({ id }) as AssetEntity), - }); - } - - // Merge stacks - options.stackParentId = undefined; - (options as Partial).updatedAt = new Date(); - } - for (const id of ids) { await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); } await this.assetRepository.updateAll(ids, options); - const stackIdsToDelete = await Promise.all(stackIdsToCheckForDelete.map((id) => this.stackRepository.getById(id))); - const stacksToDelete = stackIdsToDelete - .flatMap((stack) => (stack ? [stack] : [])) - .filter((stack) => stack.assets.length < 2); - await Promise.all(stacksToDelete.map((as) => this.stackRepository.delete(as.id))); - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, ids); } async handleAssetDeletionCheck(): Promise { @@ -343,41 +288,6 @@ export class AssetService { } } - async updateStackParent(auth: AuthDto, dto: UpdateStackParentDto): Promise { - const { oldParentId, newParentId } = dto; - await this.access.requirePermission(auth, Permission.ASSET_READ, oldParentId); - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, newParentId); - - const childIds: string[] = []; - const oldParent = await this.assetRepository.getById(oldParentId, { - faces: { - person: true, - }, - library: true, - stack: { - assets: true, - }, - }); - if (!oldParent?.stackId) { - throw new Error('Asset not found or not in a stack'); - } - if (oldParent != null) { - // Get all children of old parent - childIds.push(oldParent.id, ...(oldParent.stack?.assets.map((a) => a.id) ?? [])); - } - await this.stackRepository.update({ - id: oldParent.stackId, - primaryAssetId: newParentId, - }); - - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, [ - ...childIds, - newParentId, - oldParentId, - ]); - await this.assetRepository.updateAll([oldParentId, newParentId, ...childIds], { updatedAt: new Date() }); - } - async run(auth: AuthDto, dto: AssetJobsDto) { await this.access.requirePermission(auth, Permission.ASSET_UPDATE, dto.assetIds); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index ae9d101c58..70852a5381 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -39,7 +39,7 @@ export class DuplicateService { async getDuplicates(auth: AuthDto): Promise { const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); - return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth }))); + return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true }))); } async handleQueueSearchDuplicates({ force }: IBaseJob): Promise { diff --git a/server/src/services/index.ts b/server/src/services/index.ts index ab680f15e3..5a2e53927a 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -25,6 +25,7 @@ import { ServerService } from 'src/services/server.service'; import { SessionService } from 'src/services/session.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { SmartInfoService } from 'src/services/smart-info.service'; +import { StackService } from 'src/services/stack.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; import { SyncService } from 'src/services/sync.service'; @@ -65,6 +66,7 @@ export const services = [ SessionService, SharedLinkService, SmartInfoService, + StackService, StorageService, StorageTemplateService, SyncService, diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts new file mode 100644 index 0000000000..70234dee56 --- /dev/null +++ b/server/src/services/stack.service.ts @@ -0,0 +1,84 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { AccessCore } from 'src/cores/access.core'; +import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto'; +import { Permission } from 'src/enum'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IStackRepository } from 'src/interfaces/stack.interface'; + +@Injectable() +export class StackService { + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IEventRepository) private eventRepository: IEventRepository, + @Inject(IStackRepository) private stackRepository: IStackRepository, + ) { + this.access = AccessCore.create(accessRepository); + } + + async search(auth: AuthDto, dto: StackSearchDto): Promise { + const stacks = await this.stackRepository.search({ + ownerId: auth.user.id, + primaryAssetId: dto.primaryAssetId, + }); + + return stacks.map((stack) => mapStack(stack, { auth })); + } + + async create(auth: AuthDto, dto: StackCreateDto): Promise { + await this.access.requirePermission(auth, Permission.ASSET_UPDATE, dto.assetIds); + + const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds }); + + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + + return mapStack(stack, { auth }); + } + + async get(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.STACK_READ, id); + const stack = await this.findOrFail(id); + return mapStack(stack, { auth }); + } + + async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise { + await this.access.requirePermission(auth, Permission.STACK_UPDATE, id); + const stack = await this.findOrFail(id); + if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) { + throw new BadRequestException('Primary asset must be in the stack'); + } + + const updatedStack = await this.stackRepository.update({ id, primaryAssetId: dto.primaryAssetId }); + + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + + return mapStack(updatedStack, { auth }); + } + + async delete(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.STACK_DELETE, id); + await this.stackRepository.delete(id); + + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + } + + async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise { + await this.access.requirePermission(auth, Permission.STACK_DELETE, dto.ids); + await this.stackRepository.deleteAll(dto.ids); + + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + } + + private async findOrFail(id: string) { + const stack = await this.stackRepository.getById(id); + if (!stack) { + throw new Error('Asset stack not found'); + } + + return stack; + } +} diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 1635f8d24f..8a5cc17d4f 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -76,7 +76,6 @@ const assetResponse: AssetResponseDto = { isTrashed: false, libraryId: 'library-id', hasMetadata: true, - stackCount: 0, }; const assetResponseWithoutMetadata = { diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 8d69e35c05..befe9c77a8 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -7,10 +7,11 @@ export interface IAccessRepositoryMock { asset: Mocked; album: Mocked; authDevice: Mocked; - timeline: Mocked; memory: Mocked; person: Mocked; partner: Mocked; + stack: Mocked; + timeline: Mocked; } export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => { @@ -42,10 +43,6 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, - timeline: { - checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), - }, - memory: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, @@ -58,5 +55,13 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => partner: { checkUpdateAccess: vitest.fn().mockResolvedValue(new Set()), }, + + stack: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + + timeline: { + checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, }; }; diff --git a/server/test/repositories/stack.repository.mock.ts b/server/test/repositories/stack.repository.mock.ts index 5567d2e1ac..35d1614de7 100644 --- a/server/test/repositories/stack.repository.mock.ts +++ b/server/test/repositories/stack.repository.mock.ts @@ -3,9 +3,11 @@ import { Mocked, vitest } from 'vitest'; export const newStackRepositoryMock = (): Mocked => { return { + search: vitest.fn(), create: vitest.fn(), update: vitest.fn(), delete: vitest.fn(), getById: vitest.fn(), + deleteAll: vitest.fn(), }; }; diff --git a/web/src/lib/components/asset-viewer/actions/unstack-action.svelte b/web/src/lib/components/asset-viewer/actions/unstack-action.svelte index 40178c472d..bd18e0e8bf 100644 --- a/web/src/lib/components/asset-viewer/actions/unstack-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/unstack-action.svelte @@ -1,17 +1,17 @@
- +
{#if isFromExternalLibrary}
{$t('external')}
{/if} - {#if stackCount != null && stackCount != 0} + {#if asset.stack?.assetCount}
-
{stackCount}
+
{asset.stack.assetCount}
diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 74a695770e..2722745317 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -12,9 +12,12 @@ import { createAlbum } from '$lib/utils/album-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { addAssetsToAlbum as addAssets, + createStack, + deleteStacks, getAssetInfo, getBaseUrl, getDownloadInfo, + getStack, updateAsset, updateAssets, type AlbumResponseDto, @@ -335,79 +338,60 @@ export const stackAssets = async (assets: AssetResponseDto[], showNotification = return false; } - const parent = assets[0]; - const children = assets.slice(1); - const ids = children.map(({ id }) => id); const $t = get(t); try { - await updateAssets({ - assetBulkUpdateDto: { - ids, - stackParentId: parent.id, - }, - }); + const stack = await createStack({ stackCreateDto: { assetIds: assets.map(({ id }) => id) } }); + if (showNotification) { + notificationController.show({ + message: $t('stacked_assets_count', { values: { count: stack.assets.length } }), + type: NotificationType.Info, + button: { + text: $t('view_stack'), + onClick: () => assetViewingStore.setAssetId(stack.primaryAssetId), + }, + }); + } + + for (const [index, asset] of assets.entries()) { + asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null; + } + + return assets.slice(1).map((asset) => asset.id); } catch (error) { handleError(error, $t('errors.failed_to_stack_assets')); return false; } - - let grandChildren: AssetResponseDto[] = []; - for (const asset of children) { - asset.stackParentId = parent.id; - if (asset.stack) { - // Add grand-children to new parent - grandChildren = grandChildren.concat(asset.stack); - // Reset children stack info - asset.stackCount = null; - asset.stack = []; - } - } - - parent.stack ??= []; - parent.stack = parent.stack.concat(children, grandChildren); - parent.stackCount = parent.stack.length + 1; - - if (showNotification) { - notificationController.show({ - message: $t('stacked_assets_count', { values: { count: parent.stackCount } }), - type: NotificationType.Info, - button: { - text: $t('view_stack'), - onClick() { - return assetViewingStore.setAssetId(parent.id); - }, - }, - }); - } - - return ids; }; -export const unstackAssets = async (assets: AssetResponseDto[]) => { - const ids = assets.map(({ id }) => id); - const $t = get(t); - try { - await updateAssets({ - assetBulkUpdateDto: { - ids, - removeParent: true, - }, - }); - } catch (error) { - handleError(error, $t('errors.failed_to_unstack_assets')); +export const deleteStack = async (stackIds: string[]) => { + const ids = [...new Set(stackIds)]; + if (ids.length === 0) { return; } - for (const asset of assets) { - asset.stackParentId = null; - asset.stackCount = null; - asset.stack = []; + + const $t = get(t); + + try { + const stacks = await Promise.all(ids.map((id) => getStack({ id }))); + const count = stacks.reduce((sum, stack) => sum + stack.assets.length, 0); + + await deleteStacks({ bulkIdsDto: { ids: [...ids] } }); + + notificationController.show({ + type: NotificationType.Info, + message: $t('unstacked_assets_count', { values: { count } }), + }); + + const assets = stacks.flatMap((stack) => stack.assets); + for (const asset of assets) { + asset.stack = null; + } + + return assets; + } catch (error) { + handleError(error, $t('errors.failed_to_unstack_assets')); } - notificationController.show({ - type: NotificationType.Info, - message: $t('unstacked_assets_count', { values: { count: assets.length } }), - }); - return assets; }; export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => { diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index e76138fe59..5f31b8af44 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -25,5 +25,4 @@ export const assetFactory = Sync.makeFactory({ checksum: Sync.each(() => faker.string.alphanumeric(28)), isOffline: Sync.each(() => faker.datatype.boolean()), hasMetadata: Sync.each(() => faker.datatype.boolean()), - stackCount: null, });