From 4a8887f37be5329736a8d97c2471dc3560293f2f Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shalong-tanwen@users.noreply.github.com> Date: Fri, 6 Oct 2023 07:01:14 +0000 Subject: [PATCH] feat(server): trash asset (#4015) * refactor(server): delete assets endpoint * fix: formatting * chore: cleanup * chore: open api * chore(mobile): replace DeleteAssetDTO with BulkIdsDTOs * feat: trash an asset * chore(server): formatting * chore: open api * chore: wording * chore: open-api * feat(server): add withDeleted to getAssets queries * WIP: mobile-recycle-bin * feat(server): recycle-bin to system config * feat(web): use recycle-bin system config * chore(server): domain assetcore removed * chore(server): rename recycle-bin to trash * chore(web): rename recycle-bin to trash * chore(server): always send soft deleted assets for getAllByUserId * chore(web): formatting * feat(server): permanent delete assets older than trashed period * feat(web): trash empty placeholder image * feat(server): empty trash * feat(web): empty trash * WIP: mobile-recycle-bin * refactor(server): empty / restore trash to separate endpoint * test(server): handle failures * test(server): fix e2e server-info test * test(server): deletion test refactor * feat(mobile): use map settings from server-config to enable / disable map * feat(mobile): trash asset * fix(server): operations on assets in trash * feat(web): show trash statistics * fix(web): handle trash enabled * fix(mobile): restore updates from trash * fix(server): ignore trashed assets for person * fix(server): add / remove search index when trashed / restored * chore(web): format * fix(server): asset service test * fix(server): include trashed assts for duplicates from uploads * feat(mobile): no dialog for trash, always dialog for permanent delete * refactor(mobile): use isar where instead of dart filter * refactor(mobile): asset provide - handle deletes in single db txn * chore(mobile): review changes * feat(web): confirmation before empty trash * server: review changes * fix(server): handle library changes * fix: filter external assets from getting trashed / deleted * fix(server): empty-bin * feat: broadcast config update events through ws * change order of trash button on mobile * styling * fix(mobile): do not show trashed toast for local only assets --------- Co-authored-by: Jason Rasmussen Co-authored-by: Alex Tran --- cli/src/api/open-api/api.ts | 437 ++++++++++++++---- mobile/assets/i18n/en-US.json | 14 +- mobile/ios/Podfile.lock | 2 +- .../providers/archive_asset_provider.dart | 1 + .../asset_viewer/views/gallery_viewer.dart | 64 ++- .../backup/views/backup_controller_page.dart | 4 +- .../favorite/providers/favorite_provider.dart | 1 + .../home/ui/control_bottom_app_bar.dart | 25 +- .../ui/profile_drawer/profile_drawer.dart | 28 ++ mobile/lib/modules/home/views/home_page.dart | 18 +- .../providers/trashed_asset.provider.dart | 144 ++++++ .../modules/trash/services/trash.service.dart | 48 ++ .../lib/modules/trash/views/trash_page.dart | 276 +++++++++++ mobile/lib/routing/router.dart | 2 + mobile/lib/routing/router.gr.dart | 26 ++ mobile/lib/shared/models/asset.dart | 25 +- mobile/lib/shared/models/asset.g.dart | Bin 72400 -> 74000 bytes .../lib/shared/providers/asset.provider.dart | 42 +- .../providers/server_info.provider.dart | 2 + .../shared/providers/websocket.provider.dart | 7 + mobile/lib/shared/services/asset.service.dart | 22 +- mobile/openapi/.openapi-generator/FILES | 15 +- mobile/openapi/README.md | Bin 20728 -> 20968 bytes mobile/openapi/doc/AssetApi.md | Bin 60943 -> 66689 bytes ...eleteAssetDto.md => AssetBulkDeleteDto.md} | Bin 434 -> 476 bytes mobile/openapi/doc/AssetResponseDto.md | Bin 1697 -> 1728 bytes mobile/openapi/doc/DeleteAssetResponseDto.md | Bin 479 -> 0 bytes mobile/openapi/doc/ServerConfigDto.md | Bin 494 -> 524 bytes mobile/openapi/doc/ServerFeaturesDto.md | Bin 735 -> 762 bytes mobile/openapi/doc/SystemConfigDto.md | Bin 1131 -> 1201 bytes ...AssetStatus.md => SystemConfigTrashDto.md} | Bin 383 -> 440 bytes mobile/openapi/lib/api.dart | Bin 6847 -> 6811 bytes mobile/openapi/lib/api/asset_api.dart | Bin 56145 -> 59142 bytes mobile/openapi/lib/api_client.dart | Bin 20677 -> 20580 bytes mobile/openapi/lib/api_helper.dart | Bin 5240 -> 5128 bytes ...et_dto.dart => asset_bulk_delete_dto.dart} | Bin 2784 -> 3492 bytes .../openapi/lib/model/asset_response_dto.dart | Bin 11362 -> 11618 bytes .../lib/model/delete_asset_status.dart | Bin 2727 -> 0 bytes .../openapi/lib/model/server_config_dto.dart | Bin 3496 -> 3750 bytes .../lib/model/server_features_dto.dart | Bin 5532 -> 5740 bytes .../openapi/lib/model/system_config_dto.dart | Bin 5205 -> 5438 bytes ..._dto.dart => system_config_trash_dto.dart} | Bin 3089 -> 3064 bytes mobile/openapi/test/asset_api_test.dart | Bin 5645 -> 5986 bytes ...t.dart => asset_bulk_delete_dto_test.dart} | Bin 588 -> 693 bytes .../openapi/test/asset_response_dto_test.dart | Bin 3446 -> 3547 bytes .../test/delete_asset_status_test.dart | Bin 431 -> 0 bytes .../openapi/test/server_config_dto_test.dart | Bin 805 -> 905 bytes .../test/server_features_dto_test.dart | Bin 1607 -> 1700 bytes .../openapi/test/system_config_dto_test.dart | Bin 1569 -> 1678 bytes ...dart => system_config_trash_dto_test.dart} | Bin 686 -> 670 bytes .../test/asset_grid_data_structure_test.dart | 1 + mobile/test/sync_service_test.dart | 1 + server/immich-openapi-specs.json | 218 ++++++--- server/src/domain/access/access.core.ts | 4 + server/src/domain/asset/asset.repository.ts | 11 +- server/src/domain/asset/asset.service.spec.ts | 219 ++++++++- server/src/domain/asset/asset.service.ts | 124 ++++- .../domain/asset/dto/asset-statistics.dto.ts | 5 + server/src/domain/asset/dto/asset.dto.ts | 11 + .../src/domain/asset/dto/time-bucket.dto.ts | 5 + .../asset/response-dto/asset-response.dto.ts | 2 + .../communication/communication.repository.ts | 2 + server/src/domain/job/job.constants.ts | 6 + server/src/domain/job/job.interface.ts | 4 + server/src/domain/job/job.repository.ts | 3 + server/src/domain/job/job.service.spec.ts | 1 + server/src/domain/job/job.service.ts | 1 + .../domain/library/library.service.spec.ts | 19 +- server/src/domain/library/library.service.ts | 24 +- .../src/domain/server-info/server-info.dto.ts | 3 + .../server-info/server-info.service.spec.ts | 2 + .../domain/server-info/server-info.service.ts | 1 + server/src/domain/system-config/dto/index.ts | 1 + .../dto/system-config-trash.dto.ts | 14 + .../system-config/dto/system-config.dto.ts | 7 +- .../system-config/system-config.core.ts | 8 +- .../system-config.service.spec.ts | 15 +- .../system-config/system-config.service.ts | 3 + .../immich/api-v1/asset/asset-repository.ts | 10 +- .../immich/api-v1/asset/asset.controller.ts | 11 - server/src/immich/api-v1/asset/asset.core.ts | 1 + .../immich/api-v1/asset/asset.service.spec.ts | 128 ----- .../src/immich/api-v1/asset/asset.service.ts | 62 --- .../api-v1/asset/dto/delete-asset.dto.ts | 17 - .../response-dto/delete-asset-response.dto.ts | 13 - .../immich/controllers/asset.controller.ts | 41 +- server/src/infra/entities/asset.entity.ts | 4 + .../infra/entities/system-config.entity.ts | 7 + .../1694204416744-AddAssetDeletedAtColumn.ts | 14 + .../infra/repositories/access.repository.ts | 1 + .../infra/repositories/asset.repository.ts | 71 +-- .../repositories/communication.repository.ts | 4 + .../infra/repositories/person.repository.ts | 1 + server/src/microservices/app.service.ts | 4 + server/test/e2e/server-info.e2e-spec.ts | 2 + server/test/fixtures/asset.stub.ts | 19 +- server/test/fixtures/shared-link.stub.ts | 2 + .../repositories/asset.repository.mock.ts | 6 +- .../communication.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 437 ++++++++++++++---- web/src/lib/assets/empty-3.svg | 1 + .../settings/map-settings/map-settings.svelte | 4 + .../trash-settings/trash-settings.svelte | 107 +++++ .../asset-viewer/asset-viewer.svelte | 54 ++- .../photos-page/actions/delete-assets.svelte | 52 ++- .../photos-page/actions/restore-assets.svelte | 43 ++ .../components/photos-page/asset-grid.svelte | 4 + .../asset-select-control-bar.svelte | 1 + .../empty-placeholder.svelte | 5 +- .../side-bar/side-bar.svelte | 19 + web/src/lib/constants.ts | 3 + web/src/lib/stores/server-config.store.ts | 2 + web/src/routes/(user)/trash/+page.server.ts | 16 + web/src/routes/(user)/trash/+page.svelte | 112 +++++ .../trash/photos/[assetId]/+page.svelte | 0 .../(user)/trash/photos/[assetId]/+page.ts | 13 + .../routes/admin/system-settings/+page.svelte | 5 + 117 files changed, 2570 insertions(+), 645 deletions(-) create mode 100644 mobile/lib/modules/trash/providers/trashed_asset.provider.dart create mode 100644 mobile/lib/modules/trash/services/trash.service.dart create mode 100644 mobile/lib/modules/trash/views/trash_page.dart rename mobile/openapi/doc/{DeleteAssetDto.md => AssetBulkDeleteDto.md} (84%) delete mode 100644 mobile/openapi/doc/DeleteAssetResponseDto.md rename mobile/openapi/doc/{DeleteAssetStatus.md => SystemConfigTrashDto.md} (79%) rename mobile/openapi/lib/model/{delete_asset_dto.dart => asset_bulk_delete_dto.dart} (51%) delete mode 100644 mobile/openapi/lib/model/delete_asset_status.dart rename mobile/openapi/lib/model/{delete_asset_response_dto.dart => system_config_trash_dto.dart} (50%) rename mobile/openapi/test/{delete_asset_dto_test.dart => asset_bulk_delete_dto_test.dart} (69%) delete mode 100644 mobile/openapi/test/delete_asset_status_test.dart rename mobile/openapi/test/{delete_asset_response_dto_test.dart => system_config_trash_dto_test.dart} (59%) create mode 100644 server/src/domain/system-config/dto/system-config-trash.dto.ts delete mode 100644 server/src/immich/api-v1/asset/dto/delete-asset.dto.ts delete mode 100644 server/src/immich/api-v1/asset/response-dto/delete-asset-response.dto.ts create mode 100644 server/src/infra/migrations/1694204416744-AddAssetDeletedAtColumn.ts create mode 100644 web/src/lib/assets/empty-3.svg create mode 100644 web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte create mode 100644 web/src/lib/components/photos-page/actions/restore-assets.svelte create mode 100644 web/src/routes/(user)/trash/+page.server.ts create mode 100644 web/src/routes/(user)/trash/+page.svelte create mode 100644 web/src/routes/(user)/trash/photos/[assetId]/+page.svelte create mode 100644 web/src/routes/(user)/trash/photos/[assetId]/+page.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index c71b4705db..405bcd1d5a 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -356,6 +356,25 @@ export interface AllJobStatusResponseDto { */ 'videoConversion': JobStatusDto; } +/** + * + * @export + * @interface AssetBulkDeleteDto + */ +export interface AssetBulkDeleteDto { + /** + * + * @type {boolean} + * @memberof AssetBulkDeleteDto + */ + 'force'?: boolean; + /** + * + * @type {Array} + * @memberof AssetBulkDeleteDto + */ + 'ids': Array; +} /** * * @export @@ -657,6 +676,12 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'isReadOnly': boolean; + /** + * + * @type {boolean} + * @memberof AssetResponseDto + */ + 'isTrashed': boolean; /** * * @type {string} @@ -1357,54 +1382,6 @@ export interface CuratedObjectsResponseDto { */ 'resizePath': string; } -/** - * - * @export - * @interface DeleteAssetDto - */ -export interface DeleteAssetDto { - /** - * - * @type {Array} - * @memberof DeleteAssetDto - */ - 'ids': Array; -} -/** - * - * @export - * @interface DeleteAssetResponseDto - */ -export interface DeleteAssetResponseDto { - /** - * - * @type {string} - * @memberof DeleteAssetResponseDto - */ - 'id': string; - /** - * - * @type {DeleteAssetStatus} - * @memberof DeleteAssetResponseDto - */ - 'status': DeleteAssetStatus; -} - - -/** - * - * @export - * @enum {string} - */ - -export const DeleteAssetStatus = { - Success: 'SUCCESS', - Failed: 'FAILED' -} as const; - -export type DeleteAssetStatus = typeof DeleteAssetStatus[keyof typeof DeleteAssetStatus]; - - /** * * @export @@ -2623,6 +2600,12 @@ export interface ServerConfigDto { * @memberof ServerConfigDto */ 'oauthButtonText': string; + /** + * + * @type {number} + * @memberof ServerConfigDto + */ + 'trashDays': number; } /** * @@ -2696,6 +2679,12 @@ export interface ServerFeaturesDto { * @memberof ServerFeaturesDto */ 'tagImage': boolean; + /** + * + * @type {boolean} + * @memberof ServerFeaturesDto + */ + 'trash': boolean; } /** * @@ -3139,6 +3128,12 @@ export interface SystemConfigDto { * @memberof SystemConfigDto */ 'thumbnail': SystemConfigThumbnailDto; + /** + * + * @type {SystemConfigTrashDto} + * @memberof SystemConfigDto + */ + 'trash': SystemConfigTrashDto; } /** * @@ -3594,6 +3589,25 @@ export interface SystemConfigThumbnailDto { } +/** + * + * @export + * @interface SystemConfigTrashDto + */ +export interface SystemConfigTrashDto { + /** + * + * @type {number} + * @memberof SystemConfigTrashDto + */ + 'days': number; + /** + * + * @type {boolean} + * @memberof SystemConfigTrashDto + */ + 'enabled': boolean; +} /** * * @export @@ -5682,13 +5696,13 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }, /** * - * @param {DeleteAssetDto} deleteAssetDto + * @param {AssetBulkDeleteDto} assetBulkDeleteDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - deleteAsset: async (deleteAssetDto: DeleteAssetDto, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'deleteAssetDto' is not null or undefined - assertParamExists('deleteAsset', 'deleteAssetDto', deleteAssetDto) + deleteAssets: async (assetBulkDeleteDto: AssetBulkDeleteDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'assetBulkDeleteDto' is not null or undefined + assertParamExists('deleteAssets', 'assetBulkDeleteDto', assetBulkDeleteDto) const localVarPath = `/asset`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -5717,7 +5731,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(deleteAssetDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(assetBulkDeleteDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -5811,6 +5825,44 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + emptyTrash: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/trash/empty`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -5979,10 +6031,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * * @param {boolean} [isArchived] * @param {boolean} [isFavorite] + * @param {boolean} [isTrashed] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, options: AxiosRequestConfig = {}): Promise => { + getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, options: AxiosRequestConfig = {}): Promise => { const localVarPath = `/asset/statistics`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -6012,6 +6065,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['isFavorite'] = isFavorite; } + if (isTrashed !== undefined) { + localVarQueryParameter['isTrashed'] = isTrashed; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -6084,11 +6141,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {string} [personId] * @param {boolean} [isArchived] * @param {boolean} [isFavorite] + * @param {boolean} [isTrashed] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { + getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'size' is not null or undefined assertParamExists('getByTimeBucket', 'size', size) // verify required parameter 'timeBucket' is not null or undefined @@ -6138,6 +6196,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['isFavorite'] = isFavorite; } + if (isTrashed !== undefined) { + localVarQueryParameter['isTrashed'] = isTrashed; + } + if (timeBucket !== undefined) { localVarQueryParameter['timeBucket'] = timeBucket; } @@ -6447,11 +6509,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {string} [personId] * @param {boolean} [isArchived] * @param {boolean} [isFavorite] + * @param {boolean} [isTrashed] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { + getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'size' is not null or undefined assertParamExists('getTimeBuckets', 'size', size) const localVarPath = `/asset/time-buckets`; @@ -6499,6 +6562,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['isFavorite'] = isFavorite; } + if (isTrashed !== undefined) { + localVarQueryParameter['isTrashed'] = isTrashed; + } + if (key !== undefined) { localVarQueryParameter['key'] = key; } @@ -6600,6 +6667,88 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {BulkIdsDto} bulkIdsDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + restoreAssets: async (bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'bulkIdsDto' is not null or undefined + assertParamExists('restoreAssets', 'bulkIdsDto', bulkIdsDto) + const localVarPath = `/asset/restore`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + restoreTrash: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/trash/restore`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {AssetJobsDto} assetJobsDto @@ -7014,12 +7163,12 @@ export const AssetApiFp = function(configuration?: Configuration) { }, /** * - * @param {DeleteAssetDto} deleteAssetDto + * @param {AssetBulkDeleteDto} assetBulkDeleteDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async deleteAsset(deleteAssetDto: DeleteAssetDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAsset(deleteAssetDto, options); + async deleteAssets(assetBulkDeleteDto: AssetBulkDeleteDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAssets(assetBulkDeleteDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -7044,6 +7193,15 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async emptyTrash(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.emptyTrash(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * Get all AssetEntity belong to the user * @param {string} [userId] @@ -7083,11 +7241,12 @@ export const AssetApiFp = function(configuration?: Configuration) { * * @param {boolean} [isArchived] * @param {boolean} [isFavorite] + * @param {boolean} [isTrashed] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAssetStats(isArchived?: boolean, isFavorite?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, options); + async getAssetStats(isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, isTrashed, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -7111,12 +7270,13 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {string} [personId] * @param {boolean} [isArchived] * @param {boolean} [isFavorite] + * @param {boolean} [isTrashed] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, key, options); + async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -7190,12 +7350,13 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {string} [personId] * @param {boolean} [isArchived] * @param {boolean} [isFavorite] + * @param {boolean} [isTrashed] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key, options); + async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -7218,6 +7379,25 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {BulkIdsDto} bulkIdsDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async restoreAssets(bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.restoreAssets(bulkIdsDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async restoreTrash(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.restoreTrash(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {AssetJobsDto} assetJobsDto @@ -7336,12 +7516,12 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath }, /** * - * @param {AssetApiDeleteAssetRequest} requestParameters Request parameters. + * @param {AssetApiDeleteAssetsRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - deleteAsset(requestParameters: AssetApiDeleteAssetRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(axios, basePath)); + deleteAssets(requestParameters: AssetApiDeleteAssetsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.deleteAssets(requestParameters.assetBulkDeleteDto, options).then((request) => request(axios, basePath)); }, /** * @@ -7361,6 +7541,14 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath downloadFile(requestParameters: AssetApiDownloadFileRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + emptyTrash(options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.emptyTrash(options).then((request) => request(axios, basePath)); + }, /** * Get all AssetEntity belong to the user * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters. @@ -7394,7 +7582,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(axios, basePath)); + return localVarFp.getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, options).then((request) => request(axios, basePath)); }, /** * @@ -7412,7 +7600,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(axios, basePath)); + return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * @@ -7473,7 +7661,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(axios, basePath)); + return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * Get all asset of a device that are in the database, ID only. @@ -7493,6 +7681,23 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiRestoreAssetsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + restoreAssets(requestParameters: AssetApiRestoreAssetsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.restoreAssets(requestParameters.bulkIdsDto, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + restoreTrash(options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.restoreTrash(options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters. @@ -7600,17 +7805,17 @@ export interface AssetApiCheckExistingAssetsRequest { } /** - * Request parameters for deleteAsset operation in AssetApi. + * Request parameters for deleteAssets operation in AssetApi. * @export - * @interface AssetApiDeleteAssetRequest + * @interface AssetApiDeleteAssetsRequest */ -export interface AssetApiDeleteAssetRequest { +export interface AssetApiDeleteAssetsRequest { /** * - * @type {DeleteAssetDto} - * @memberof AssetApiDeleteAsset + * @type {AssetBulkDeleteDto} + * @memberof AssetApiDeleteAssets */ - readonly deleteAssetDto: DeleteAssetDto + readonly assetBulkDeleteDto: AssetBulkDeleteDto } /** @@ -7744,6 +7949,13 @@ export interface AssetApiGetAssetStatsRequest { * @memberof AssetApiGetAssetStats */ readonly isFavorite?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiGetAssetStats + */ + readonly isTrashed?: boolean } /** @@ -7829,6 +8041,13 @@ export interface AssetApiGetByTimeBucketRequest { */ readonly isFavorite?: boolean + /** + * + * @type {boolean} + * @memberof AssetApiGetByTimeBucket + */ + readonly isTrashed?: boolean + /** * * @type {string} @@ -7976,6 +8195,13 @@ export interface AssetApiGetTimeBucketsRequest { */ readonly isFavorite?: boolean + /** + * + * @type {boolean} + * @memberof AssetApiGetTimeBuckets + */ + readonly isTrashed?: boolean + /** * * @type {string} @@ -8012,6 +8238,20 @@ export interface AssetApiImportFileRequest { readonly importAssetDto: ImportAssetDto } +/** + * Request parameters for restoreAssets operation in AssetApi. + * @export + * @interface AssetApiRestoreAssetsRequest + */ +export interface AssetApiRestoreAssetsRequest { + /** + * + * @type {BulkIdsDto} + * @memberof AssetApiRestoreAssets + */ + readonly bulkIdsDto: BulkIdsDto +} + /** * Request parameters for runAssetJobs operation in AssetApi. * @export @@ -8271,13 +8511,13 @@ export class AssetApi extends BaseAPI { /** * - * @param {AssetApiDeleteAssetRequest} requestParameters Request parameters. + * @param {AssetApiDeleteAssetsRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof AssetApi */ - public deleteAsset(requestParameters: AssetApiDeleteAssetRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(this.axios, this.basePath)); + public deleteAssets(requestParameters: AssetApiDeleteAssetsRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).deleteAssets(requestParameters.assetBulkDeleteDto, options).then((request) => request(this.axios, this.basePath)); } /** @@ -8302,6 +8542,16 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public emptyTrash(options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).emptyTrash(options).then((request) => request(this.axios, this.basePath)); + } + /** * Get all AssetEntity belong to the user * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters. @@ -8342,7 +8592,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, options).then((request) => request(this.axios, this.basePath)); } /** @@ -8364,7 +8614,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** @@ -8439,7 +8689,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** @@ -8464,6 +8714,27 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiRestoreAssetsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public restoreAssets(requestParameters: AssetApiRestoreAssetsRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).restoreAssets(requestParameters.bulkIdsDto, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public restoreTrash(options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).restoreTrash(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters. diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 935e880d8a..a7bfd13676 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -322,5 +322,17 @@ "map_no_location_permission_title": "Location Permission denied", "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", "map_location_dialog_cancel": "Cancel", - "map_location_dialog_yes": "Yes" + "map_location_dialog_yes": "Yes", + "trash_page_title": "Trash ({})", + "trash_page_info": "Backed up items will be permanently deleted after {} days", + "trash_page_no_assets": "No trashed assets", + "trash_page_delete": "Delete", + "trash_page_delete_all": "Delete All", + "trash_page_restore": "Restore", + "trash_page_restore_all": "Restore All", + "trash_page_select_btn": "Select", + "trash_page_select_assets_btn": "Select assets", + "trash_page_empty_trash_btn": "Empty trash", + "trash_page_empty_trash_dialog_ok": "Ok", + "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich" } diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index c6c23d942a..75168ce1c9 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -169,4 +169,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382 -COCOAPODS: 1.12.1 +COCOAPODS: 1.11.3 diff --git a/mobile/lib/modules/archive/providers/archive_asset_provider.dart b/mobile/lib/modules/archive/providers/archive_asset_provider.dart index 1f151b9228..328b3c7b58 100644 --- a/mobile/lib/modules/archive/providers/archive_asset_provider.dart +++ b/mobile/lib/modules/archive/providers/archive_asset_provider.dart @@ -17,6 +17,7 @@ final archiveProvider = StreamProvider((ref) async* { .ownerIdEqualToAnyChecksum(user.isarId) .filter() .isArchivedEqualTo(true) + .isTrashedEqualTo(false) .sortByFileCreatedAt(); final settings = ref.watch(appSettingsServiceProvider); final groupBy = diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index ad6c2c6e6e..9b2c705206 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -6,6 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; @@ -19,11 +20,14 @@ import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart' import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; import 'package:immich_mobile/shared/cache/original_image_provider.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart'; @@ -67,6 +71,12 @@ class GalleryViewerPage extends HookConsumerWidget { final header = {"Authorization": authToken}; final currentIndex = useState(initialIndex); final currentAsset = loadAsset(currentIndex.value); + final isTrashEnabled = + ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); + final navStack = AutoRouter.of(context).stackData; + final isFromTrash = isTrashEnabled && + navStack.length > 2 && + navStack.elementAt(navStack.length - 2).name == TrashRoute.name; Asset asset() => currentAsset; @@ -161,25 +171,47 @@ class GalleryViewerPage extends HookConsumerWidget { ); } - void handleDelete(Asset deleteAsset) { + void handleDelete(Asset deleteAsset) async { + Future onDelete(bool force) async { + final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( + {deleteAsset}, + force: force, + ); + if (isDeleted) { + if (totalAssets == 1) { + // Handle only one asset + AutoRouter.of(context).pop(); + } else { + // Go to next page otherwise + controller.nextPage( + duration: const Duration(milliseconds: 100), + curve: Curves.fastLinearToSlowEaseIn, + ); + } + } + return isDeleted; + } + + // Asset is trashed + if (isTrashEnabled && !isFromTrash) { + final isDeleted = await onDelete(false); + // Can only trash assets stored in server. Local assets are always permanently removed for now + if (context.mounted && isDeleted && deleteAsset.isRemote) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'Asset trashed', + gravity: ToastGravity.BOTTOM, + ); + } + return; + } + + // Asset is permanently removed showDialog( context: context, builder: (BuildContext _) { - return DeleteDialog( - onDelete: () { - if (totalAssets == 1) { - // Handle only one asset - AutoRouter.of(context).pop(); - } else { - // Go to next page otherwise - controller.nextPage( - duration: const Duration(milliseconds: 100), - curve: Curves.fastLinearToSlowEaseIn, - ); - } - ref.watch(assetProvider.notifier).deleteAssets({deleteAsset}); - }, - ); + return DeleteDialog(onDelete: () => onDelete(true)); }, ); } diff --git a/mobile/lib/modules/backup/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart index 33584bb357..0901a4d1d1 100644 --- a/mobile/lib/modules/backup/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -81,7 +81,9 @@ class BackupControllerPage extends HookConsumerWidget { context: context, msg: "Deleting ${assets.length} assets on the server...", ); - await ref.read(assetProvider.notifier).deleteAssets(assets); + await ref + .read(assetProvider.notifier) + .deleteAssets(assets, force: true); ImmichToast.show( context: context, msg: "Deleted ${assets.length} assets on the server. " diff --git a/mobile/lib/modules/favorite/providers/favorite_provider.dart b/mobile/lib/modules/favorite/providers/favorite_provider.dart index 2439d627fa..427d2c88b0 100644 --- a/mobile/lib/modules/favorite/providers/favorite_provider.dart +++ b/mobile/lib/modules/favorite/providers/favorite_provider.dart @@ -17,6 +17,7 @@ final favoriteAssetsProvider = StreamProvider((ref) async* { .ownerIdEqualToAnyChecksum(user.isarId) .filter() .isFavoriteEqualTo(true) + .isTrashedEqualTo(false) .sortByFileCreatedAt(); final settings = ref.watch(appSettingsServiceProvider); final groupBy = diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 24e2e7ce78..6315cf1d4f 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/models/album.dart'; @@ -43,6 +44,8 @@ class ControlBottomAppBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { var isDarkMode = Theme.of(context).brightness == Brightness.dark; var hasRemote = selectionAssetState == AssetState.remote; + final trashEnabled = + ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); Widget renderActionButtons() { return Row( @@ -70,14 +73,20 @@ class ControlBottomAppBar extends ConsumerWidget { iconData: Icons.delete_outline_rounded, label: "control_bottom_app_bar_delete".tr(), onPressed: enabled - ? () => showDialog( - context: context, - builder: (BuildContext context) { - return DeleteDialog( - onDelete: onDelete, - ); - }, - ) + ? () { + if (!trashEnabled) { + showDialog( + context: context, + builder: (BuildContext context) { + return DeleteDialog( + onDelete: onDelete, + ); + }, + ); + } else { + onDelete(); + } + } : null, ), if (!hasRemote) diff --git a/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart b/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart index fbc9b4ed79..5fce82054c 100644 --- a/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart +++ b/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dar import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart'; class ProfileDrawer extends HookConsumerWidget { @@ -16,6 +17,9 @@ class ProfileDrawer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final trashEnabled = + ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); + buildSignOutButton() { return ListTile( leading: SizedBox( @@ -91,6 +95,29 @@ class ProfileDrawer extends HookConsumerWidget { ); } + buildTrashButton() { + return ListTile( + leading: SizedBox( + height: double.infinity, + child: Icon( + Icons.delete_rounded, + color: Theme.of(context).textTheme.labelMedium?.color, + size: 20, + ), + ), + title: Text( + "Trash", + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(fontWeight: FontWeight.bold), + ).tr(), + onTap: () { + AutoRouter.of(context).push(const TrashRoute()); + }, + ); + } + return Drawer( shape: const RoundedRectangleBorder( borderRadius: BorderRadius.zero, @@ -105,6 +132,7 @@ class ProfileDrawer extends HookConsumerWidget { const ProfileDrawerHeader(), buildSettingButton(), buildAppLogButton(), + if (trashEnabled) buildTrashButton(), buildSignOutButton(), ], ), diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 7fcd88be44..5682f6f7cc 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -43,6 +43,8 @@ class HomePage extends HookConsumerWidget { final sharedAlbums = ref.watch(sharedAlbumProvider); final albumService = ref.watch(albumServiceProvider); final currentUser = ref.watch(currentUserProvider); + final trashEnabled = + ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); final tipOneOpacity = useState(0.0); final refreshCount = useState(0); @@ -139,7 +141,21 @@ class HomePage extends HookConsumerWidget { void onDelete() async { processing.value = true; try { - await ref.read(assetProvider.notifier).deleteAssets(selection.value); + await ref + .read(assetProvider.notifier) + .deleteAssets(selection.value, force: !trashEnabled); + + final hasRemote = selection.value.any((a) => a.isRemote); + final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset'; + final trashOrRemoved = + !trashEnabled ? 'deleted permanently' : 'trashed'; + if (hasRemote) { + ImmichToast.show( + context: context, + msg: '${selection.value.length} $assetOrAssets $trashOrRemoved', + gravity: ToastGravity.BOTTOM, + ); + } selectionEnabledHook.value = false; } finally { processing.value = false; diff --git a/mobile/lib/modules/trash/providers/trashed_asset.provider.dart b/mobile/lib/modules/trash/providers/trashed_asset.provider.dart new file mode 100644 index 0000000000..38629f7c49 --- /dev/null +++ b/mobile/lib/modules/trash/providers/trashed_asset.provider.dart @@ -0,0 +1,144 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; +import 'package:immich_mobile/modules/trash/services/trash.service.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; +import 'package:isar/isar.dart'; +import 'package:logging/logging.dart'; + +class TrashNotifier extends StateNotifier { + final Isar _db; + final Ref _ref; + final TrashService _trashService; + final _log = Logger('TrashNotifier'); + + TrashNotifier( + this._trashService, + this._db, + this._ref, + ) : super(false); + + Future emptyTrash() async { + try { + final user = _ref.read(currentUserProvider); + if (user == null) { + return; + } + await _trashService.emptyTrash(); + + final dbIds = await _db.assets + .where() + .remoteIdIsNotNull() + .filter() + .ownerIdEqualTo(user.isarId) + .isTrashedEqualTo(true) + .idProperty() + .findAll(); + + await _db.writeTxn(() async { + await _db.exifInfos.deleteAll(dbIds); + await _db.assets.deleteAll(dbIds); + }); + + // Refresh assets in background + Future.delayed( + const Duration(seconds: 4), + () async => await _ref.read(assetProvider.notifier).getAllAsset(), + ); + } catch (error, stack) { + _log.severe("Cannot empty trash ${error.toString()}", error, stack); + } + } + + Future restoreAssets(Iterable assetList) async { + try { + final result = await _trashService.restoreAssets(assetList); + + if (result) { + final remoteAssets = assetList.where((a) => a.isRemote).toList(); + + final updatedAssets = remoteAssets.map((e) { + e.isTrashed = false; + return e; + }).toList(); + + await _db.writeTxn(() async { + await _db.assets.putAll(updatedAssets); + }); + + // Refresh assets in background + Future.delayed( + const Duration(seconds: 4), + () async => await _ref.read(assetProvider.notifier).getAllAsset(), + ); + return true; + } + } catch (error, stack) { + _log.severe("Cannot restore trash ${error.toString()}", error, stack); + } + return false; + } + + Future restoreTrash() async { + try { + final user = _ref.read(currentUserProvider); + if (user == null) { + return; + } + await _trashService.restoreTrash(); + + final assets = await _db.assets + .where() + .remoteIdIsNotNull() + .filter() + .ownerIdEqualTo(user.isarId) + .isTrashedEqualTo(true) + .findAll(); + + final updatedAssets = assets.map((e) { + e.isTrashed = false; + return e; + }).toList(); + + await _db.writeTxn(() async { + await _db.assets.putAll(updatedAssets); + }); + + // Refresh assets in background + Future.delayed( + const Duration(seconds: 4), + () async => await _ref.read(assetProvider.notifier).getAllAsset(), + ); + } catch (error, stack) { + _log.severe("Cannot restore trash ${error.toString()}", error, stack); + } + } +} + +final trashProvider = StateNotifierProvider((ref) { + return TrashNotifier( + ref.watch(trashServiceProvider), + ref.watch(dbProvider), + ref, + ); +}); + +final trashedAssetsProvider = StreamProvider((ref) async* { + final user = ref.read(currentUserProvider); + if (user == null) return; + final query = ref + .watch(dbProvider) + .assets + .filter() + .ownerIdEqualTo(user.isarId) + .isTrashedEqualTo(true) + .sortByFileCreatedAt(); + const groupBy = GroupAssetsBy.none; + yield await RenderList.fromQuery(query, groupBy); + await for (final _ in query.watchLazy()) { + yield await RenderList.fromQuery(query, groupBy); + } +}); diff --git a/mobile/lib/modules/trash/services/trash.service.dart b/mobile/lib/modules/trash/services/trash.service.dart new file mode 100644 index 0000000000..1accff4ec4 --- /dev/null +++ b/mobile/lib/modules/trash/services/trash.service.dart @@ -0,0 +1,48 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; + +final trashServiceProvider = Provider((ref) { + return TrashService( + ref.watch(apiServiceProvider), + ); +}); + +class TrashService { + final _log = Logger("TrashService"); + + final ApiService _apiService; + + TrashService(this._apiService); + + Future restoreAssets(Iterable assetList) async { + try { + List remoteIds = + assetList.where((a) => a.isRemote).map((e) => e.remoteId!).toList(); + await _apiService.assetApi.restoreAssets(BulkIdsDto(ids: remoteIds)); + return true; + } catch (error, stack) { + _log.severe("Cannot restore assets ${error.toString()}", error, stack); + return false; + } + } + + Future emptyTrash() async { + try { + await _apiService.assetApi.emptyTrash(); + } catch (error, stack) { + _log.severe("Cannot empty trash ${error.toString()}", error, stack); + } + } + + Future restoreTrash() async { + try { + await _apiService.assetApi.restoreTrash(); + } catch (error, stack) { + _log.severe("Cannot restore trash ${error.toString()}", error, stack); + } + } +} diff --git a/mobile/lib/modules/trash/views/trash_page.dart b/mobile/lib/modules/trash/views/trash_page.dart new file mode 100644 index 0000000000..797b08a75c --- /dev/null +++ b/mobile/lib/modules/trash/views/trash_page.dart @@ -0,0 +1,276 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; +import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; +import 'package:immich_mobile/modules/trash/providers/trashed_asset.provider.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; +import 'package:immich_mobile/shared/ui/confirm_dialog.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; + +class TrashPage extends HookConsumerWidget { + const TrashPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final trashedAssets = ref.watch(trashedAssetsProvider); + final trashDays = + ref.watch(serverInfoProvider.select((v) => v.serverConfig.trashDays)); + final selectionEnabledHook = useState(false); + final selection = useState({}); + final processing = useState(false); + + void selectionListener( + bool multiselect, + Set selectedAssets, + ) { + selectionEnabledHook.value = multiselect; + selection.value = selectedAssets; + } + + onEmptyTrash() async { + processing.value = true; + await ref.read(trashProvider.notifier).emptyTrash(); + processing.value = false; + selectionEnabledHook.value = false; + if (context.mounted) { + ImmichToast.show( + context: context, + msg: 'Emptied trash', + gravity: ToastGravity.BOTTOM, + ); + } + } + + handleEmptyTrash() async { + await showDialog( + context: context, + builder: (context) => ConfirmDialog( + onOk: () => onEmptyTrash(), + title: "trash_page_empty_trash_btn".tr(), + ok: "trash_page_empty_trash_dialog_ok".tr(), + content: "trash_page_empty_trash_dialog_content".tr(), + ), + ); + } + + Future onPermanentlyDelete() async { + processing.value = true; + try { + if (selection.value.isNotEmpty) { + await ref + .read(assetProvider.notifier) + .deleteAssets(selection.value, force: true); + + final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset'; + if (context.mounted) { + ImmichToast.show( + context: context, + msg: + '${selection.value.length} $assetOrAssets deleted permanently', + gravity: ToastGravity.BOTTOM, + ); + } + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + + handlePermanentDelete() async { + await showDialog( + context: context, + builder: (context) => DeleteDialog( + onDelete: () => onPermanentlyDelete(), + ), + ); + } + + Future handleRestoreAll() async { + processing.value = true; + await ref.read(trashProvider.notifier).restoreTrash(); + processing.value = false; + selectionEnabledHook.value = false; + } + + Future handleRestore() async { + processing.value = true; + try { + if (selection.value.isNotEmpty) { + final result = await ref + .read(trashProvider.notifier) + .restoreAssets(selection.value); + + final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset'; + if (result && context.mounted) { + ImmichToast.show( + context: context, + msg: + '${selection.value.length} $assetOrAssets restored successfully', + gravity: ToastGravity.BOTTOM, + ); + } + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + + String getAppBarTitle(String count) { + if (selectionEnabledHook.value) { + return selection.value.isNotEmpty + ? "${selection.value.length}" + : "trash_page_select_assets_btn".tr(); + } + return 'trash_page_title'.tr(args: [count]); + } + + AppBar buildAppBar(String count) { + return AppBar( + leading: IconButton( + onPressed: !selectionEnabledHook.value + ? () => AutoRouter.of(context).pop() + : () { + selectionEnabledHook.value = false; + selection.value = {}; + }, + icon: !selectionEnabledHook.value + ? const Icon(Icons.arrow_back_ios_rounded) + : const Icon(Icons.close_rounded), + ), + centerTitle: !selectionEnabledHook.value, + automaticallyImplyLeading: false, + title: Text(getAppBarTitle(count)), + actions: [ + if (!selectionEnabledHook.value) + PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: () => selectionEnabledHook.value = true, + child: const Text('trash_page_select_btn').tr(), + ), + PopupMenuItem( + value: handleEmptyTrash, + child: const Text('trash_page_empty_trash_btn').tr(), + ), + ]; + }, + onSelected: (fn) => fn(), + ), + ], + ); + } + + Widget buildBottomBar() { + return SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + height: 64, + child: Container( + color: Theme.of(context).canvasColor, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + icon: Icon( + Icons.delete_forever, + color: Colors.red[400], + ), + label: Text( + selection.value.isEmpty + ? 'trash_page_delete_all'.tr() + : 'trash_page_delete'.tr(), + style: TextStyle( + fontSize: 14, + color: Colors.red[400], + fontWeight: FontWeight.bold, + ), + ), + onPressed: processing.value + ? null + : selection.value.isEmpty + ? handleEmptyTrash + : handlePermanentDelete, + ), + TextButton.icon( + icon: const Icon( + Icons.history_rounded, + ), + label: Text( + selection.value.isEmpty + ? 'trash_page_restore_all'.tr() + : 'trash_page_restore'.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + onPressed: processing.value + ? null + : selection.value.isEmpty + ? handleRestoreAll + : handleRestore, + ), + ], + ), + ), + ), + ), + ); + } + + return trashedAssets.when( + loading: () => Scaffold( + appBar: buildAppBar("?"), + body: const Center(child: CircularProgressIndicator()), + ), + error: (error, stackTrace) => Scaffold( + appBar: buildAppBar("!"), + body: Center(child: Text(error.toString())), + ), + data: (data) => Scaffold( + appBar: buildAppBar(data.totalAssets.toString()), + body: data.isEmpty + ? Center( + child: Text('trash_page_no_assets'.tr()), + ) + : Stack( + children: [ + SafeArea( + child: ImmichAssetGrid( + renderList: data, + listener: selectionListener, + selectionActive: selectionEnabledHook.value, + showMultiSelectIndicator: false, + topWidget: Padding( + padding: const EdgeInsets.only( + top: 24, + bottom: 24, + left: 12, + right: 12, + ), + child: const Text( + "trash_page_info", + ).tr(args: ["$trashDays"]), + ), + ), + ), + if (selectionEnabledHook.value) buildBottomBar(), + if (processing.value) + const Center(child: ImmichLoadingIndicator()), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 9b2592e46a..c3f4c2c1ad 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -28,6 +28,7 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart'; import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart'; +import 'package:immich_mobile/modules/trash/views/trash_page.dart'; import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart'; import 'package:immich_mobile/modules/search/views/all_people_page.dart'; import 'package:immich_mobile/modules/search/views/all_videos_page.dart'; @@ -155,6 +156,7 @@ part 'router.gr.dart'; AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]), + AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]), ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 87c362ad83..6502d5585b 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -312,6 +312,12 @@ class _$AppRouter extends RootStackRouter { ), ); }, + TrashRoute.name: (routeData) { + return MaterialPageX( + routeData: routeData, + child: const TrashPage(), + ); + }, HomeRoute.name: (routeData) { return MaterialPageX( routeData: routeData, @@ -624,6 +630,14 @@ class _$AppRouter extends RootStackRouter { duplicateGuard, ], ), + RouteConfig( + TrashRoute.name, + path: '/trash-page', + guards: [ + authGuard, + duplicateGuard, + ], + ), ]; } @@ -1394,6 +1408,18 @@ class AlbumOptionsRouteArgs { } } +/// generated route for +/// [TrashPage] +class TrashRoute extends PageRouteInfo { + const TrashRoute() + : super( + TrashRoute.name, + path: '/trash-page', + ); + + static const String name = 'TrashRoute'; +} + /// generated route for /// [HomePage] class HomeRoute extends PageRouteInfo { diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index cea12e7b62..74d9380be9 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -30,7 +30,8 @@ class Asset { exifInfo = remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null, isFavorite = remote.isFavorite, - isArchived = remote.isArchived; + isArchived = remote.isArchived, + isTrashed = remote.isTrashed; Asset.local(AssetEntity local, List hash) : localId = local.id, @@ -45,6 +46,7 @@ class Asset { updatedAt = local.modifiedDateTime, isFavorite = local.isFavorite, isArchived = false, + isTrashed = false, fileCreatedAt = local.createDateTime { if (fileCreatedAt.year == 1970) { fileCreatedAt = fileModifiedAt; @@ -74,6 +76,7 @@ class Asset { this.exifInfo, required this.isFavorite, required this.isArchived, + required this.isTrashed, }); @ignore @@ -138,6 +141,8 @@ class Asset { bool isArchived; + bool isTrashed; + @ignore ExifInfo? exifInfo; @@ -194,7 +199,8 @@ class Asset { livePhotoVideoId == other.livePhotoVideoId && isFavorite == other.isFavorite && isLocal == other.isLocal && - isArchived == other.isArchived; + isArchived == other.isArchived && + isTrashed == other.isTrashed; } @override @@ -216,7 +222,8 @@ class Asset { livePhotoVideoId.hashCode ^ isFavorite.hashCode ^ isLocal.hashCode ^ - isArchived.hashCode; + isArchived.hashCode ^ + isTrashed.hashCode; /// Returns `true` if this [Asset] can updated with values from parameter [a] bool canUpdate(Asset a) { @@ -229,8 +236,9 @@ class Asset { width == null && a.width != null || height == null && a.height != null || livePhotoVideoId == null && a.livePhotoVideoId != null || - !isRemote && a.isRemote && isFavorite != a.isFavorite || - !isRemote && a.isRemote && isArchived != a.isArchived; + isFavorite != a.isFavorite || + isArchived != a.isArchived || + isTrashed != a.isTrashed; } /// Returns a new [Asset] with values from this and merged & updated with [a] @@ -261,6 +269,7 @@ class Asset { livePhotoVideoId: livePhotoVideoId, isFavorite: isFavorite, isArchived: isArchived, + isTrashed: isTrashed, ); } } else { @@ -275,6 +284,7 @@ class Asset { // isFavorite + isArchived are not set by device-only assets isFavorite: a.isFavorite, isArchived: a.isArchived, + isTrashed: a.isTrashed, exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo, ); } else { @@ -306,6 +316,7 @@ class Asset { String? livePhotoVideoId, bool? isFavorite, bool? isArchived, + bool? isTrashed, ExifInfo? exifInfo, }) => Asset( @@ -325,6 +336,7 @@ class Asset { livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, isFavorite: isFavorite ?? this.isFavorite, isArchived: isArchived ?? this.isArchived, + isTrashed: isTrashed ?? this.isTrashed, exifInfo: exifInfo ?? this.exifInfo, ); @@ -378,7 +390,8 @@ class Asset { "storage": "$storage", "width": ${width ?? "N/A"}, "height": ${height ?? "N/A"}, - "isArchived": $isArchived + "isArchived": $isArchived, + "isTrashed": $isTrashed, }"""; } } diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart index 606f9692e99c7cd22577f125b690687ed4e92de6..f06e556eaf19cd245d18fe690d1447cae1faeb7f 100644 GIT binary patch delta 553 zcmcbxm1V*qmJL?yoSDTTMTx~3sVS2`aLP_jXBT9LicDU>raRe+U3cd zID{s@Vvn4>hC^_&B%i?K)olEe`&fi0w{fUV7U2|}yq6=K(Ri{bXYXV_F2TvJr-deG zv#L#g&8f_2x|yGgmuvD%VO|l7SRIA@q^#8B5gomJL?ylb5pVPEKGKoV;YJ9+NMz3r+Uty_IkkkcgekQq^X)=$j>g07Y zag+UI0lzU7|IE6XUeJza)zB?|zbx?b4; diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index a4ded2b532..715d99164a 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -15,7 +15,6 @@ import 'package:immich_mobile/shared/services/user.service.dart'; import 'package:immich_mobile/utils/db.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; import 'package:photo_manager/photo_manager.dart'; class AssetNotifier extends StateNotifier { @@ -92,23 +91,45 @@ class AssetNotifier extends StateNotifier { await _syncService.syncNewAssetToDb(newAsset); } - Future deleteAssets(Iterable deleteAssets) async { + Future deleteAssets( + Iterable deleteAssets, { + bool? force = false, + }) async { _deleteInProgress = true; state = true; try { final localDeleted = await _deleteLocalAssets(deleteAssets); - final remoteDeleted = await _deleteRemoteAssets(deleteAssets); + final remoteDeleted = await _deleteRemoteAssets(deleteAssets, force); if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) { - final dbIds = deleteAssets.map((e) => e.id).toList(); + List? assetsToUpdate; + // Local only assets are permanently deleted for now. So always remove them from db + final dbIds = deleteAssets + .where((a) => a.isLocal && !a.isRemote) + .map((e) => e.id) + .toList(); + if (force == null || !force) { + assetsToUpdate = remoteDeleted.map((e) { + e.isTrashed = true; + return e; + }).toList(); + } else { + // Add all remote assets to be deleted from isar as since they are permanently deleted + dbIds.addAll(remoteDeleted.map((e) => e.id)); + } await _db.writeTxn(() async { + if (assetsToUpdate != null) { + await _db.assets.putAll(assetsToUpdate); + } await _db.exifInfos.deleteAll(dbIds); await _db.assets.deleteAll(dbIds); }); + return true; } } finally { _deleteInProgress = false; state = false; } + return false; } Future> _deleteLocalAssets( @@ -127,15 +148,14 @@ class AssetNotifier extends StateNotifier { return []; } - Future> _deleteRemoteAssets( + Future> _deleteRemoteAssets( Iterable assetsToDelete, + bool? force, ) async { final Iterable remote = assetsToDelete.where((e) => e.isRemote); - final List deleteAssetResult = - await _assetService.deleteAssets(remote) ?? []; - return deleteAssetResult - .where((a) => a.status == DeleteAssetStatus.SUCCESS) - .map((a) => a.id); + + final isSuccess = await _assetService.deleteAssets(remote, force: force); + return isSuccess ? remote : []; } Future toggleFavorite(List assets, bool status) async { @@ -190,6 +210,7 @@ final assetsProvider = .ownerIdEqualToAnyChecksum(userId) .filter() .isArchivedEqualTo(false) + .isTrashedEqualTo(false) .sortByFileCreatedAtDesc(); final settings = ref.watch(appSettingsServiceProvider); final groupBy = @@ -210,6 +231,7 @@ final remoteAssetsProvider = .remoteIdIsNotNull() .filter() .ownerIdEqualTo(userId) + .isTrashedEqualTo(false) .sortByFileCreatedAtDesc(); final settings = ref.watch(appSettingsServiceProvider); final groupBy = diff --git a/mobile/lib/shared/providers/server_info.provider.dart b/mobile/lib/shared/providers/server_info.provider.dart index e9f8d011d2..b4e05ffc93 100644 --- a/mobile/lib/shared/providers/server_info.provider.dart +++ b/mobile/lib/shared/providers/server_info.provider.dart @@ -26,12 +26,14 @@ class ServerInfoNotifier extends StateNotifier { search: true, sidecar: true, tagImage: true, + trash: true, reverseGeocoding: true, ), serverConfig: ServerConfigDto( loginPageMessage: "", mapTileUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", oauthButtonText: "", + trashDays: 30, ), isVersionMismatch: false, versionMismatchErrorMessage: "", diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart index 7741b8c766..acc3fd87b9 100644 --- a/mobile/lib/shared/providers/websocket.provider.dart +++ b/mobile/lib/shared/providers/websocket.provider.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/modules/login/providers/authentication.provider.da import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:socket_io_client/socket_io_client.dart'; @@ -92,6 +93,7 @@ class WebsocketNotifier extends StateNotifier { }); socket.on('on_upload_success', _handleOnUploadSuccess); + socket.on('on_config_update', _handleOnConfigUpdate); } catch (e) { debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); } @@ -126,6 +128,11 @@ class WebsocketNotifier extends StateNotifier { ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset); } } + + _handleOnConfigUpdate(dynamic data) { + ref.read(serverInfoProvider.notifier).getServerFeatures(); + ref.read(serverInfoProvider.notifier).getServerConfig(); + } } final websocketProvider = diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index b0eb9228a3..488395b16a 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -64,7 +64,9 @@ class AssetService { Future?> _getRemoteAssets(User user) async { try { final List? assets = - await _apiService.assetApi.getAllAssets(userId: user.id); + await _apiService.assetApi.getAllAssets( + userId: user.id, + ); if (assets == null) { return null; } else if (assets.isNotEmpty && assets.first.ownerId != user.id) { @@ -84,9 +86,10 @@ class AssetService { } } - Future?> deleteAssets( - Iterable deleteAssets, - ) async { + Future deleteAssets( + Iterable deleteAssets, { + bool? force = false, + }) async { try { final List payload = []; @@ -94,12 +97,17 @@ class AssetService { payload.add(asset.remoteId!); } - return await _apiService.assetApi - .deleteAsset(DeleteAssetDto(ids: payload)); + await _apiService.assetApi.deleteAssets( + AssetBulkDeleteDto( + ids: payload, + force: force, + ), + ); + return true; } catch (error, stack) { log.severe("Error deleteAssets ${error.toString()}", error, stack); - return null; } + return false; } /// Loads the exif information from the database. If there is none, loads diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 60dc2f1521..494835c0a7 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -15,6 +15,7 @@ doc/AlbumCountResponseDto.md doc/AlbumResponseDto.md doc/AllJobStatusResponseDto.md doc/AssetApi.md +doc/AssetBulkDeleteDto.md doc/AssetBulkUpdateDto.md doc/AssetBulkUploadCheckDto.md doc/AssetBulkUploadCheckItem.md @@ -53,9 +54,6 @@ doc/CreateTagDto.md doc/CreateUserDto.md doc/CuratedLocationsResponseDto.md doc/CuratedObjectsResponseDto.md -doc/DeleteAssetDto.md -doc/DeleteAssetResponseDto.md -doc/DeleteAssetStatus.md doc/DownloadArchiveInfo.md doc/DownloadInfoDto.md doc/DownloadResponseDto.md @@ -131,6 +129,7 @@ doc/SystemConfigReverseGeocodingDto.md doc/SystemConfigStorageTemplateDto.md doc/SystemConfigTemplateStorageOptionDto.md doc/SystemConfigThumbnailDto.md +doc/SystemConfigTrashDto.md doc/TagApi.md doc/TagResponseDto.md doc/TagTypeEnum.md @@ -186,6 +185,7 @@ lib/model/api_key_create_dto.dart lib/model/api_key_create_response_dto.dart lib/model/api_key_response_dto.dart lib/model/api_key_update_dto.dart +lib/model/asset_bulk_delete_dto.dart lib/model/asset_bulk_update_dto.dart lib/model/asset_bulk_upload_check_dto.dart lib/model/asset_bulk_upload_check_item.dart @@ -222,9 +222,6 @@ lib/model/create_tag_dto.dart lib/model/create_user_dto.dart lib/model/curated_locations_response_dto.dart lib/model/curated_objects_response_dto.dart -lib/model/delete_asset_dto.dart -lib/model/delete_asset_response_dto.dart -lib/model/delete_asset_status.dart lib/model/download_archive_info.dart lib/model/download_info_dto.dart lib/model/download_response_dto.dart @@ -291,6 +288,7 @@ lib/model/system_config_reverse_geocoding_dto.dart lib/model/system_config_storage_template_dto.dart lib/model/system_config_template_storage_option_dto.dart lib/model/system_config_thumbnail_dto.dart +lib/model/system_config_trash_dto.dart lib/model/tag_response_dto.dart lib/model/tag_type_enum.dart lib/model/thumbnail_format.dart @@ -322,6 +320,7 @@ test/api_key_create_response_dto_test.dart test/api_key_response_dto_test.dart test/api_key_update_dto_test.dart test/asset_api_test.dart +test/asset_bulk_delete_dto_test.dart test/asset_bulk_update_dto_test.dart test/asset_bulk_upload_check_dto_test.dart test/asset_bulk_upload_check_item_test.dart @@ -360,9 +359,6 @@ test/create_tag_dto_test.dart test/create_user_dto_test.dart test/curated_locations_response_dto_test.dart test/curated_objects_response_dto_test.dart -test/delete_asset_dto_test.dart -test/delete_asset_response_dto_test.dart -test/delete_asset_status_test.dart test/download_archive_info_test.dart test/download_info_dto_test.dart test/download_response_dto_test.dart @@ -438,6 +434,7 @@ test/system_config_reverse_geocoding_dto_test.dart test/system_config_storage_template_dto_test.dart test/system_config_template_storage_option_dto_test.dart test/system_config_thumbnail_dto_test.dart +test/system_config_trash_dto_test.dart test/tag_api_test.dart test/tag_response_dto_test.dart test/tag_type_enum_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4e3cadff6537297f1cea04030fb08a545266559e..8cf1cca47e9e76537a7a744f39f377a12ced3386 100644 GIT binary patch delta 241 zcmeydknzQ0#tpgrlQ#*9v8ANuq?V*kRxlKsJeU6&S88rSNo7b;VsXagMj=rMw*<_c zASS{MX6b{~OlA}p+1xDfkd-U7xFo+Q)v>rZb@E0*arPnzf3iNm6r2rI%L5gMn++3y zo6ZX{9i|^-ezMpS1vwY63tdX`V>MFpllApMHkCM)=468NWwv>#Rd6!#i>~IOfGO2(?n7lT#{H)T8wEj QM11pN``Nsk%Y2Ji08FDrlmGw# diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 1affd29f6ce2c43b9334375bf3ed689d02cdfc6b..30914b10cea1b6e89e9db32afb984c0b00ec3d2d 100644 GIT binary patch delta 1080 zcmZ`&O=uHA6ee*)=%!Wz_0ZJHNJ8CiG(Vss#A;I_qBljbjV*PX40O#;m&~XXmDY=( z9s<6jHz}TL=OS38BBdUcUP?()ywrnA!LtWkGRI-F%nl%eO8=sA%WrDoR|XC75GHOyz&Ka{hB_NeT4bIr#h@tq?P z@21S#yP?7sq61R&pqV{D_)dmpH-$2O(-mWVWi|e{CD9GUS$T8^I zL4dBQ(7JoWgH4oVzXJ>Bqws3_JToezX%EKYgSZ}Je_w|?bKY9K?BXF&8e@MJ>n>FA zf>8;uk8kF~gZ~@ad}84_;jn4bTvv;PWj6<_wt^2g@?fj41$0p73lb=%4urdW&b_Tu)go?Upi GarPghK9|k_ delta 395 zcmZqdWa+=dyx}>cw3b$^hGTJYYKdb(re1D}a!P7WYDsD$NMte#)6vbs%$HdWIbt%cWQ-J6Ni)(<`n`b=JWzj+s1*%f8RY1}X z6wuJLR?yc63r^O3Z7PJM9BfEQ>g12NA|~r-a!+oRXf;NX25Q#QQm9dg*3v>X667Z& zk;#c)<|8C-rk}{%@?tviWvFCgbFWKMIo*y0j*5EDD+|FA=;s tv3M)vW{1)$Lx`H!t4tAUde^vu)flXu#5mcZ)ONGx`g+05>(5;>1pr3>n!*48 diff --git a/mobile/openapi/doc/DeleteAssetDto.md b/mobile/openapi/doc/AssetBulkDeleteDto.md similarity index 84% rename from mobile/openapi/doc/DeleteAssetDto.md rename to mobile/openapi/doc/AssetBulkDeleteDto.md index 3b60d4ecedd2b3c138c417e06b9724441b1447b1..ff38a456e2bdd9241f425800c9d710e88daca4c0 100644 GIT binary patch delta 78 zcmdnQe1}0l$l2S;#Tg1s2uU|%u!t_Hs4WTUG8FpbdvXnR+o9DW-p9S; zUI9L!)4^7wH#k%c4oFz1gmlCdyi3@S7osTO$!i1jf-pHD=<}<-qUihH=t#nj!AFAC z%-_wqo1C2utNU!7k`9r@NEVaW>k;7{p5H?L8cgSC16l7F?LDzWEgq8#q|{$nNNJ6f zY?Jat(Yosd7_*W{(=AOzE9K(w(=2rBUt~3!lBaWkXxj&_0La5GR|kVl)OG8s+8noa vv)i(_F#dcS(QzQ3q%JJWTvIFBf0JroC$p8QV delta 11 ScmeBSdB?oLlW}r7<5B<_U<4ch diff --git a/mobile/openapi/doc/ServerFeaturesDto.md b/mobile/openapi/doc/ServerFeaturesDto.md index 8c772e3b91e05310905a61432fe8afdaf3f8ba0b..8f845345b3c5cc5e589501e619e28cead6b96b31 100644 GIT binary patch delta 22 dcmcc5`ipgg6%(75R!LD}@#F+X(aBa!O95IZ2VnpJ delta 11 ScmeyxdY^TJ71QKUrlkNKBm`do diff --git a/mobile/openapi/doc/SystemConfigDto.md b/mobile/openapi/doc/SystemConfigDto.md index f91a540635cb8b3ea03ec01e36968e864eee7d04..a5b8db773c5b67612fa5318172d16102ee73cbb6 100644 GIT binary patch delta 39 mcmaFOv5|AbF=loxt&*a|;*80P%p$BIVAexMVJK?{^JV}O^bQ69 delta 11 ScmdnU`I=+HG3LoUEK30$BLr^% diff --git a/mobile/openapi/doc/DeleteAssetStatus.md b/mobile/openapi/doc/SystemConfigTrashDto.md similarity index 79% rename from mobile/openapi/doc/DeleteAssetStatus.md rename to mobile/openapi/doc/SystemConfigTrashDto.md index 4616d0ae06ff1d7d6672cade52a607c24791d78b..1cc2de7cfdabd4539ecef05c2dbabb23eed8caa8 100644 GIT binary patch delta 101 zcmey*w1ZhiSs}k5H7~IsQ!h6^B{fGcxU#q;HP<;mFD)}Yq$sgC!=)sDqT)_dEv=Nq o%3>`og&GAdt<1acyw)L#6sX3`7sgrF4-`~k)odQS_j?PAeKztu=?|4HRZWKo)7OGEnP~e(8 zIczS-6b`7hI+F#hVkY13 zy(NZmvO$c+YRHM@c>SZcDqVjnPN%1u@{ z}#Ie#f delta 429 zcmZoW$9!=c^9C(RF_+Yw)RI)k;^Nd2my-Ngg%mJFvY$@81ICKpI@PiE)h6-8AB zQ*~fl=;Zrn>{vjWCQmr_YqGWUc049J_+%EB*dQ4dlv-SnpI4j;wAs$y4r(gcEgDFw zfU*b+#V6kv6`$NA19lcS$YQALH~X_aVw}9buZj(#b+TfLHjL?$5;J*1f#~KM+2y>G zIZcfx*9nGAc9=nC=X|h1V0icM? zWCcF)$s&nUHz&_bVw$YkKWXy%ERM~)Q`B`p+LR`Cg0)FZVrJfaVSfzcWPu5nCSRDo U5w7*2DU-G7;ar+;J}-01sp)L;wH) diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 986305f02ecf4c08daf7b61b9cfa3d84455f6013..ee254516df0aa8f5dae0fffb6c3cb3bd8445f003 100644 GIT binary patch delta 12 TcmeyN(V?;7EXQVBPHS!eBz6Rq delta 66 zcmeCs_@S}kEQg^>YEEiNs$+3+YDsWOVo7PSrb0EB0uU6XmXsFdDPYJ?-oR$R`7DPu FHvmI^7?}V7 diff --git a/mobile/openapi/lib/model/delete_asset_dto.dart b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart similarity index 51% rename from mobile/openapi/lib/model/delete_asset_dto.dart rename to mobile/openapi/lib/model/asset_bulk_delete_dto.dart index b6909a4189bdee4d2636d1477ccc39db2a3d809b..d36456e97821e20f75e55083886b121d850329fb 100644 GIT binary patch delta 1111 zcmZ`&J8u&~5SE>nZ6qF%A_{~wj*uV1*fcbL0!e`c5)p|aAuCAhyE)$`n_cU6&ryVs zJBly!3&BPL%dpEy2DL)a6ZCk% zJ!ZI)5O9HWa$ljg)TG)Iv5z#Ej{%Ek!oZY?7@-zxF2^a$W*QQvz4=!{y#b**!6`j?>LSJxgy1OQ#HhrKyB%gEpEq8lRsZcU~ zid>Dd1wyZv5hXYChn1AlLUtq=z(Z(f(Q-3nI@}^7>To|X`F7c*AEwp4d)Z+?@;(1r z@&Ia!Yk_jcTu(33Dmm0j4x?&XQazyiStNH-t?vK@-A{IRa?rOtDcTT9PxToIOx zm(CrmxP0>LN*?_uwaguomFj83F`Mk$GkO{9(Pq2W_I}MuZonVS^+xhx`J>aIyRze8 z=PTU7uC0E#?F#AJ&dIOBMsm4&cioLAgW{v)@!ETb?&sQ92RkeD9PFa-#=$zp9}afC GbnzR|$at3k delta 347 zcmZ1?{XlesI-`I~YEEiNs$+3+YKcopzC!ing{)#asDiNynR&$}iFwJXdRz($sB#+B zlczE&PgZ44<3p90yo)(&asZ17KdNx_UH2MKzel2wR(+_F3 z7S{E8aOcSsWVTX^txE9u(ChWGQaDFbWaQ<zAP zhIY!Y$*(1Ct$9RjIV~SWS;(TDc?$Uch&gjESK_PoA7M0$-F1r4eOGTjQL%FJ%nbkT^9qGk> zD;HL1huPUk+rLP1nVDSnUlhOg#MvJSbxb8Lt`2?vjj|2s+r2R*Jy^37jMaz=Q93zz zx(I#S^ofLbHWByn9D{PL#Vbezw{AePa}6jir9#~I{_G1U#JtrkWAa711Jhj_|_CbS=yZ$)@kff6#wLwckk$(woz2f?t=Act7} z6*aAs3oqziWM5hOh>_$JFksCOYUZ|wembRqX@+QvT~k4|ybfg4a)9M==nc1pZv zn+RUNGbO|@1cKs+NQ^BjOvAu9uF;cCB>@cHYT~ocw$%YmDmUhbY3~$R4{Bx7~5{27bpLN zM~b8DG{(4wzTZ@yOWx34TTn0?m$PL(<}wzf4v9GI>ueowUs6jZoTTSfc3Bs)8&Fd3 zj7XYHKDBDS%-+z963>dcECguz5_va&9QawA*2@4{!gDogVjn7g=?YcupWo@P8a0kD z2(dI6h}f`~^c}+xH*@3T4WeYi<@msavA5?a8eWJ%gI#Jm7SRqDgXcg diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 75fe4b2bf7ec75558e520cdc71c34a9444e03dc2..3f4950ec500ba350118b71d18a72f4c5e816f00b 100644 GIT binary patch delta 269 zcmZ1>y-aq)6Glk|g`(8L(#)dN6orzE%woNgqQv41m&D5A$qyJKcr)`#6c8fDOdnJg z)YP~XKp?*)Bee*jT*1~BNu@IL3PA;JutE)l{)5bO#dHv=Y*i3aaV+mt6%?|H^YfyM z)ZxPFu?j#_QJl5ejn$3GNC9Drl|pV}L0Do=X{vu(NM%8)4baVY8X%K&6p)PC+{muT TCa$1>WVjB{eYMtHwOm{PbDmp- delta 48 zcmV-00MGxX9jF_y*Fd8WxPm>zGw#mvvNnT@TMaq}#8d&bSrIUJZadvR;AY+l8`9w=w$9@NBl0sH- zeqMBuI#^6SRsm>8MrJX@$jwJMC0UFWAlj@Hu(?Vvttda&3uuG}vN@YC^475lD=0us O)d4!C)|#u9iwgj3u~60k delta 48 zcmdm|byZ_S2h-+-OnHo(^;ottZenv_+#Jr%%(yv&;|}BIbS`0*%{%#u*w|{VxoWw% E0G{U$6aWAK diff --git a/mobile/openapi/lib/model/delete_asset_response_dto.dart b/mobile/openapi/lib/model/system_config_trash_dto.dart similarity index 50% rename from mobile/openapi/lib/model/delete_asset_response_dto.dart rename to mobile/openapi/lib/model/system_config_trash_dto.dart index 6e435adcb6884ffddd6b92462ddcbb3485563384..13e354eaf18fc8de1938e1b732f5b5657b754cda 100644 GIT binary patch delta 838 zcmbOz@k4xrI-^){WpPPru5*4~T4s7kQDSk1OG&;$^&}QCV{9_93YmGuC5d^-sd`)r z3K%LT_b^JZrX*Gt>r9@=D6E~Dmzb23nxX?#TB~Wz1*9_bN)$lyU`|qgevSfE&E$`a zVF4KCf!v~|2Gj=x`6U^tMS3933bwXjL(rt4CV^yOhE8S*lm+R~%K&=JIX@*;Q$ZVS zng&e8KPE#%Y}Q7DwAreFNFA7Qwkj}PG0ahxASv}&1zUxZjLc%NyR0D&RmdvN&xf$4t9tSg9N=VVQm=~zTBvy|6ks09X#2GlMEa7b7wP$qyOBe2^rOov)?_wn!nrBqOy5Xs?2;Ezmp#RAGoQAStMU?M#7E znJIc1iNzVt`6;QI3ff>(G@#O7nGAKY+ZUagVygo59n>IO6{wDI<|yOL6!lmITUY>C zL)@j1Rh*v}U8D|Chfo03_lj9p7>iAlHCU!&6~Dkzjw7x#p!O*My>F$En^+K*m{XeS zpB7SCkZJ=7Xgdv%V{{aX)PYXbRD?tkDDohVu|kgFU~mNJr4{ApdI2?obwa$s3XDVb z*vZFO&9SMPCku~_PiAq6jZ11yYDwy3M|NTP6fo1VxHz>$10oD&yOiWBfTSj$5>aFU zGv13>Y;IsxV%)?&pJ{R-x6I`E9ITr!aJDl}HV{$X+{HbS1|W0A(8} Ay#N3J diff --git a/mobile/openapi/test/delete_asset_dto_test.dart b/mobile/openapi/test/asset_bulk_delete_dto_test.dart similarity index 69% rename from mobile/openapi/test/delete_asset_dto_test.dart rename to mobile/openapi/test/asset_bulk_delete_dto_test.dart index 4e04b65e9b14f5b61dca47c0553a797b3deae962..d4245531f0045e25d0aaa5e8de25cc01f9602b8e 100644 GIT binary patch delta 126 zcmX@ZvXym1EF-^TadB#iQ)y1NOKMJPN$TVbMvVY8F_)5j4NYqYt>`u4A-6Upa6_NgT6Q!s2kO>dI zH*aS0T;(||ciZavvD}m|%WYY}YW-ABpr~L|ZQ;2p)~mOxh$Zr&!65gu>FrDAeejZ#tU8UDf!!B=v-X4NKp%q%(QdJi)nkaiP~g}+@MU}L zEDKuErip=OZz%lP`C?dSEV1sKZ6hWJwEFHw*9Q!&)c=f-d92^DrMdHD(VINJY1%ql zoFPIic`7F$g(l4qPXa|egV2(hnt8H46FYBaUWr0UQDSk1OJZg5WpPPru5*4~T4s7kQDSk%+M6%_RK6*BWm6jBl^izmNlRAdG+Cf72l3qj?Q^7C^PQu7j% Ya#AP1V^m^;G6k8`K^E15?5pJh00^2jrvLx| delta 175 zcmbQox{h^2ETgbXYEEiNs$+3+YDrLPaY24waq8qeMvZVR;w~lm8k*KzTnY;5Mfs%# z8tNsf#U%>Z6{_nfXlN=_1Jx=h=<6#4mlS2@r7L8nOn$+r$e1}fmq}d?Dutv!xFoTp Wv{<1SL`{CqsKf?l6;IA%G5`RgDmx+o diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/asset_grid_data_structure_test.dart index 6522bec3d0..a124f5214a 100644 --- a/mobile/test/asset_grid_data_structure_test.dart +++ b/mobile/test/asset_grid_data_structure_test.dart @@ -24,6 +24,7 @@ void main() { fileName: '', isFavorite: false, isArchived: false, + isTrashed: false, ), ); } diff --git a/mobile/test/sync_service_test.dart b/mobile/test/sync_service_test.dart index 144ca2c299..9c03ec689b 100644 --- a/mobile/test/sync_service_test.dart +++ b/mobile/test/sync_service_test.dart @@ -34,6 +34,7 @@ void main() { fileName: localId ?? remoteId ?? "", isFavorite: false, isArchived: false, + isTrashed: false, ); } diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 52dd7848d1..cb3d107fcb 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -681,30 +681,20 @@ }, "/asset": { "delete": { - "operationId": "deleteAsset", + "operationId": "deleteAssets", "parameters": [], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteAssetDto" + "$ref": "#/components/schemas/AssetBulkDeleteDto" } } }, "required": true }, "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/DeleteAssetResponseDto" - }, - "type": "array" - } - } - }, + "204": { "description": "" } }, @@ -1568,6 +1558,41 @@ ] } }, + "/asset/restore": { + "post": { + "operationId": "restoreAssets", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkIdsDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ] + } + }, "/asset/search": { "post": { "operationId": "searchAsset", @@ -1667,6 +1692,14 @@ "schema": { "type": "boolean" } + }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -1817,6 +1850,14 @@ "type": "boolean" } }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "timeBucket", "required": true, @@ -1929,6 +1970,14 @@ "type": "boolean" } }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "key", "required": false, @@ -1978,6 +2027,56 @@ ] } }, + "/asset/trash/empty": { + "post": { + "operationId": "emptyTrash", + "parameters": [], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ] + } + }, + "/asset/trash/restore": { + "post": { + "operationId": "restoreTrash", + "parameters": [], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ] + } + }, "/asset/upload": { "post": { "operationId": "uploadFile", @@ -5398,6 +5497,24 @@ ], "type": "object" }, + "AssetBulkDeleteDto": { + "properties": { + "force": { + "type": "boolean" + }, + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, "AssetBulkUpdateDto": { "properties": { "ids": { @@ -5616,6 +5733,9 @@ "isReadOnly": { "type": "boolean" }, + "isTrashed": { + "type": "boolean" + }, "libraryId": { "type": "string" }, @@ -5686,6 +5806,7 @@ "updatedAt", "isFavorite", "isArchived", + "isTrashed", "localDateTime", "isOffline", "isExternal", @@ -6222,48 +6343,6 @@ ], "type": "object" }, - "DeleteAssetDto": { - "properties": { - "ids": { - "example": [ - "bf973405-3f2a-48d2-a687-2ed4167164be", - "dd41870b-5d00-46d2-924e-1d8489a0aa0f", - "fad77c3f-deef-4e7e-9608-14c1aa4e559a" - ], - "items": { - "type": "string" - }, - "title": "Array of asset IDs to delete", - "type": "array" - } - }, - "required": [ - "ids" - ], - "type": "object" - }, - "DeleteAssetResponseDto": { - "properties": { - "id": { - "type": "string" - }, - "status": { - "$ref": "#/components/schemas/DeleteAssetStatus" - } - }, - "required": [ - "status", - "id" - ], - "type": "object" - }, - "DeleteAssetStatus": { - "enum": [ - "SUCCESS", - "FAILED" - ], - "type": "string" - }, "DownloadArchiveInfo": { "properties": { "assetIds": { @@ -7225,9 +7304,13 @@ }, "oauthButtonText": { "type": "string" + }, + "trashDays": { + "type": "integer" } }, "required": [ + "trashDays", "oauthButtonText", "loginPageMessage", "mapTileUrl" @@ -7268,6 +7351,9 @@ }, "tagImage": { "type": "boolean" + }, + "trash": { + "type": "boolean" } }, "required": [ @@ -7275,6 +7361,7 @@ "configFile", "facialRecognition", "map", + "trash", "reverseGeocoding", "oauth", "oauthAutoLaunch", @@ -7630,6 +7717,9 @@ }, "thumbnail": { "$ref": "#/components/schemas/SystemConfigThumbnailDto" + }, + "trash": { + "$ref": "#/components/schemas/SystemConfigTrashDto" } }, "required": [ @@ -7641,7 +7731,8 @@ "reverseGeocoding", "storageTemplate", "job", - "thumbnail" + "thumbnail", + "trash" ], "type": "object" }, @@ -7991,6 +8082,21 @@ ], "type": "object" }, + "SystemConfigTrashDto": { + "properties": { + "days": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "days", + "enabled" + ], + "type": "object" + }, "TagResponseDto": { "properties": { "id": { diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 39e190670e..502191fd89 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -7,6 +7,7 @@ export enum Permission { ASSET_READ = 'asset.read', ASSET_UPDATE = 'asset.update', ASSET_DELETE = 'asset.delete', + ASSET_RESTORE = 'asset.restore', ASSET_SHARE = 'asset.share', ASSET_VIEW = 'asset.view', ASSET_DOWNLOAD = 'asset.download', @@ -128,6 +129,9 @@ export class AccessCore { case Permission.ASSET_DELETE: return this.repository.asset.hasOwnerAccess(authUser.id, id); + case Permission.ASSET_RESTORE: + return this.repository.asset.hasOwnerAccess(authUser.id, id); + case Permission.ASSET_SHARE: return ( (await this.repository.asset.hasOwnerAccess(authUser.id, id)) || diff --git a/server/src/domain/asset/asset.repository.ts b/server/src/domain/asset/asset.repository.ts index 442dfc649f..89a4afbf17 100644 --- a/server/src/domain/asset/asset.repository.ts +++ b/server/src/domain/asset/asset.repository.ts @@ -6,10 +6,12 @@ export type AssetStats = Record; export interface AssetStatsOptions { isFavorite?: boolean; isArchived?: boolean; + isTrashed?: boolean; } export interface AssetSearchOptions { isVisible?: boolean; + trashedBefore?: Date; type?: AssetType; order?: 'ASC' | 'DESC'; } @@ -58,6 +60,7 @@ export interface TimeBucketOptions { size: TimeBucketSize; isArchived?: boolean; isFavorite?: boolean; + isTrashed?: boolean; albumId?: string; personId?: string; userId?: string; @@ -98,7 +101,8 @@ export interface IAssetRepository { getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise; getByChecksum(userId: string, checksum: Buffer): Promise; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; - getByUserId(pagination: PaginationOptions, userId: string): Paginated; + getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated; + getById(id: string): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated; getRandom(userId: string, count: number): Promise; @@ -110,12 +114,13 @@ export interface IAssetRepository { getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; updateAll(ids: string[], options: Partial): Promise; save(asset: Pick & Partial): Promise; + remove(asset: AssetEntity): Promise; + softDeleteAll(ids: string[]): Promise; + restoreAll(ids: string[]): Promise; findLivePhotoMatch(options: LivePhotoSearchOptions): Promise; getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise; getStatistics(ownerId: string, options: AssetStatsOptions): Promise; getTimeBuckets(options: TimeBucketOptions): Promise; getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; - remove(asset: AssetEntity): Promise; - getById(assetId: string): Promise; upsertExif(exif: Partial): Promise; } diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 9640b6cfb3..08fd2e4061 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -1,20 +1,23 @@ -import { AssetType } from '@app/infra/entities'; +import { AssetEntity, AssetType } from '@app/infra/entities'; import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { IAccessRepositoryMock, assetStub, authStub, + faceStub, newAccessRepositoryMock, newAssetRepositoryMock, newCryptoRepositoryMock, newJobRepositoryMock, newStorageRepositoryMock, + newSystemConfigRepositoryMock, } from '@test'; import { when } from 'jest-when'; import { Readable } from 'stream'; import { ICryptoRepository } from '../crypto'; -import { IJobRepository, JobName } from '../job'; +import { IJobRepository, JobItem, JobName } from '../job'; import { IStorageRepository } from '../storage'; +import { ISystemConfigRepository } from '../system-config'; import { AssetStats, IAssetRepository, TimeBucketSize } from './asset.repository'; import { AssetService, UploadFieldName } from './asset.service'; import { AssetJobName, AssetStatsResponseDto, DownloadResponseDto } from './dto'; @@ -150,6 +153,7 @@ describe(AssetService.name, () => { let cryptoMock: jest.Mocked; let jobMock: jest.Mocked; let storageMock: jest.Mocked; + let configMock: jest.Mocked; it('should work', () => { expect(sut).toBeDefined(); @@ -161,7 +165,15 @@ describe(AssetService.name, () => { cryptoMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); storageMock = newStorageRepositoryMock(); - sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, storageMock); + configMock = newSystemConfigRepositoryMock(); + sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, configMock, storageMock); + + when(assetMock.getById) + .calledWith(assetStub.livePhotoStillAsset.id) + .mockResolvedValue(assetStub.livePhotoStillAsset as AssetEntity); + when(assetMock.getById) + .calledWith(assetStub.livePhotoMotionAsset.id) + .mockResolvedValue(assetStub.livePhotoMotionAsset as AssetEntity); }); describe('canUpload', () => { @@ -476,7 +488,9 @@ describe(AssetService.name, () => { downloadResponse, ); - expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.id); + expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.id, { + isVisible: true, + }); }); it('should split archives by size', async () => { @@ -596,6 +610,203 @@ describe(AssetService.name, () => { }); }); + describe('deleteAll', () => { + it('should required asset delete access for all ids', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + await expect( + sut.deleteAll(authStub.user1, { + ids: ['asset-1'], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should force delete a batch of assets', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + + await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true }); + + expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.ASSET_DELETION, data: { id: 'asset1' } }], + [{ name: JobName.ASSET_DELETION, data: { id: 'asset2' } }], + ]); + }); + + it('should soft delete a batch of assets', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + + await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false }); + + expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['asset1', 'asset2']); + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.SEARCH_REMOVE_ASSET, + data: { ids: ['asset1', 'asset2'] }, + }, + ], + ]); + }); + }); + + describe('restoreAll', () => { + it('should required asset restore access for all ids', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + await expect( + sut.deleteAll(authStub.user1, { + ids: ['asset-1'], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should restore a batch of assets', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + + await sut.restoreAll(authStub.user1, { ids: ['asset1', 'asset2'] }); + + expect(assetMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.SEARCH_INDEX_ASSET, + data: { ids: ['asset1', 'asset2'] }, + }, + ], + ]); + }); + }); + + describe('handleAssetDeletion', () => { + beforeEach(() => { + when(jobMock.queue) + .calledWith( + expect.objectContaining({ + name: JobName.ASSET_DELETION, + }), + ) + .mockImplementation(async (item: JobItem) => { + const jobData = (item as { data?: any })?.data || {}; + await sut.handleAssetDeletion(jobData); + }); + }); + + it('should remove faces', async () => { + const assetWithFace = { ...(assetStub.image as AssetEntity), faces: [faceStub.face1, faceStub.mergeFace1] }; + + when(assetMock.getById).calledWith(assetWithFace.id).mockResolvedValue(assetWithFace); + + await sut.handleAssetDeletion({ id: assetWithFace.id }); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.SEARCH_REMOVE_FACE, + data: { assetId: faceStub.face1.assetId, personId: faceStub.face1.personId }, + }, + ], + [ + { + name: JobName.SEARCH_REMOVE_FACE, + data: { assetId: faceStub.mergeFace1.assetId, personId: faceStub.mergeFace1.personId }, + }, + ], + [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetWithFace.id] } }], + [ + { + name: JobName.DELETE_FILES, + data: { + files: [ + assetWithFace.webpPath, + assetWithFace.resizePath, + assetWithFace.encodedVideoPath, + assetWithFace.sidecarPath, + assetWithFace.originalPath, + ], + }, + }, + ], + ]); + + expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace); + }); + + it('should not schedule delete-files job for readonly assets', async () => { + when(assetMock.getById) + .calledWith(assetStub.readOnly.id) + .mockResolvedValue(assetStub.readOnly as AssetEntity); + + await sut.handleAssetDeletion({ id: assetStub.readOnly.id }); + + expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetStub.readOnly.id] } }], + ]); + + expect(assetMock.remove).toHaveBeenCalledWith(assetStub.readOnly); + }); + + it('should not process assets from external library without fromExternal flag', async () => { + when(assetMock.getById) + .calledWith(assetStub.external.id) + .mockResolvedValue(assetStub.external as AssetEntity); + + await sut.handleAssetDeletion({ id: assetStub.external.id }); + + expect(jobMock.queue).not.toBeCalled(); + expect(assetMock.remove).not.toBeCalled(); + }); + + it('should process assets from external library with fromExternal flag', async () => { + when(assetMock.getById) + .calledWith(assetStub.external.id) + .mockResolvedValue(assetStub.external as AssetEntity); + + await sut.handleAssetDeletion({ id: assetStub.external.id, fromExternal: true }); + + expect(assetMock.remove).toHaveBeenCalledWith(assetStub.external); + expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetStub.external.id] } }], + [ + { + name: JobName.DELETE_FILES, + data: { + files: [ + assetStub.external.webpPath, + assetStub.external.resizePath, + assetStub.external.encodedVideoPath, + assetStub.external.sidecarPath, + ], + }, + }, + ], + ]); + }); + + it('should delete a live photo', async () => { + await sut.handleAssetDeletion({ id: assetStub.livePhotoStillAsset.id }); + + expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetStub.livePhotoStillAsset.id] } }], + [{ name: JobName.ASSET_DELETION, data: { id: assetStub.livePhotoMotionAsset.id } }], + [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetStub.livePhotoMotionAsset.id] } }], + [ + { + name: JobName.DELETE_FILES, + data: { + files: [undefined, undefined, undefined, undefined, 'fake_path/asset_1.mp4'], + }, + }, + ], + [ + { + name: JobName.DELETE_FILES, + data: { + files: [undefined, undefined, undefined, undefined, 'fake_path/asset_1.jpeg'], + }, + }, + ], + ]); + }); + }); + describe('run', () => { it('should run the refresh metadata job', async () => { accessMock.asset.hasOwnerAccess.mockResolvedValue(true); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 90c083d584..8a562354f8 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -1,6 +1,7 @@ -import { AssetEntity } from '@app/infra/entities'; +import { AssetEntity, LibraryType } from '@app/infra/entities'; import { BadRequestException, Inject, Logger } from '@nestjs/common'; import _ from 'lodash'; +import { DateTime, Duration } from 'luxon'; import { extname } from 'path'; import sanitize from 'sanitize-filename'; import { AccessCore, IAccessRepository, Permission } from '../access'; @@ -8,10 +9,12 @@ import { AuthUserDto } from '../auth'; import { ICryptoRepository } from '../crypto'; import { mimeTypes } from '../domain.constant'; import { HumanReadableSize, usePagination } from '../domain.util'; -import { IJobRepository, JobName } from '../job'; +import { IAssetDeletionJob, IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { IStorageRepository, ImmichReadStream, StorageCore, StorageFolder } from '../storage'; +import { ISystemConfigRepository, SystemConfigCore } from '../system-config'; import { IAssetRepository } from './asset.repository'; import { + AssetBulkDeleteDto, AssetBulkUpdateDto, AssetIdsDto, AssetJobName, @@ -24,11 +27,13 @@ import { MemoryLaneDto, TimeBucketAssetDto, TimeBucketDto, + TrashAction, UpdateAssetDto, mapStats, } from './dto'; import { AssetResponseDto, + BulkIdsDto, MapMarkerResponseDto, MemoryLaneResponseDto, TimeBucketResponseDto, @@ -57,6 +62,7 @@ export interface UploadFile { export class AssetService { private logger = new Logger(AssetService.name); private access: AccessCore; + private configCore: SystemConfigCore; private storageCore: StorageCore; constructor( @@ -64,10 +70,12 @@ export class AssetService { @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { this.access = new AccessCore(accessRepository); this.storageCore = new StorageCore(storageRepository); + this.configCore = new SystemConfigCore(configRepository); } canUploadFile({ authUser, fieldName, file }: UploadRequest): true { @@ -274,7 +282,9 @@ export class AssetService { if (dto.userId) { const userId = dto.userId; await this.access.requirePermission(authUser, Permission.TIMELINE_DOWNLOAD, userId); - return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, userId)); + return usePagination(PAGINATION_SIZE, (pagination) => + this.assetRepository.getByUserId(pagination, userId, { isVisible: true }), + ); } throw new BadRequestException('assetIds, albumId, or userId is required'); @@ -303,13 +313,119 @@ export class AssetService { return mapAsset(asset); } - async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto) { + async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise { const { ids, ...options } = dto; await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); await this.assetRepository.updateAll(ids, options); } + async handleAssetDeletionCheck() { + const config = await this.configCore.getConfig(); + const trashedDays = config.trash.enabled ? config.trash.days : 0; + const trashedBefore = DateTime.now() + .minus(Duration.fromObject({ days: trashedDays })) + .toJSDate(); + const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => + this.assetRepository.getAll(pagination, { trashedBefore }), + ); + + for await (const assets of assetPagination) { + for (const asset of assets) { + await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.id } }); + } + } + + return true; + } + + async handleAssetDeletion(job: IAssetDeletionJob) { + const { id, fromExternal } = job; + + const asset = await this.assetRepository.getById(id); + if (!asset) { + return false; + } + + // Ignore requests that are not from external library job but is for an external asset + if (!fromExternal && (!asset.library || asset.library.type === LibraryType.EXTERNAL)) { + return false; + } + + if (asset.faces) { + await Promise.all( + asset.faces.map(({ assetId, personId }) => + this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId } }), + ), + ); + } + + await this.assetRepository.remove(asset); + await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } }); + + // TODO refactor this to use cascades + if (asset.livePhotoVideoId) { + await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } }); + } + + const files = [asset.webpPath, asset.resizePath, asset.encodedVideoPath, asset.sidecarPath]; + if (!fromExternal) { + files.push(asset.originalPath); + } + + if (!asset.isReadOnly) { + await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } }); + } + + return true; + } + + async deleteAll(authUser: AuthUserDto, dto: AssetBulkDeleteDto): Promise { + const { ids, force } = dto; + + await this.access.requirePermission(authUser, Permission.ASSET_DELETE, ids); + + if (force) { + for (const id of ids) { + await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id } }); + } + } else { + await this.assetRepository.softDeleteAll(ids); + await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids } }); + } + } + + async handleTrashAction(authUser: AuthUserDto, action: TrashAction): Promise { + const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => + this.assetRepository.getByUserId(pagination, authUser.id, { trashedBefore: DateTime.now().toJSDate() }), + ); + + if (action == TrashAction.RESTORE_ALL) { + for await (const assets of assetPagination) { + const ids = assets.map((a) => a.id); + await this.assetRepository.restoreAll(ids); + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); + } + return; + } + + if (action == TrashAction.EMPTY_ALL) { + for await (const assets of assetPagination) { + for (const asset of assets) { + await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.id } }); + } + } + return; + } + } + + async restoreAll(authUser: AuthUserDto, dto: BulkIdsDto): Promise { + const { ids } = dto; + await this.access.requirePermission(authUser, Permission.ASSET_RESTORE, ids); + await this.assetRepository.restoreAll(ids); + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); + } + async run(authUser: AuthUserDto, dto: AssetJobsDto) { await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds); diff --git a/server/src/domain/asset/dto/asset-statistics.dto.ts b/server/src/domain/asset/dto/asset-statistics.dto.ts index 428799b37a..38b5fe159b 100644 --- a/server/src/domain/asset/dto/asset-statistics.dto.ts +++ b/server/src/domain/asset/dto/asset-statistics.dto.ts @@ -15,6 +15,11 @@ export class AssetStatsDto { @Transform(toBoolean) @Optional() isFavorite?: boolean; + + @IsBoolean() + @Transform(toBoolean) + @Optional() + isTrashed?: boolean; } export class AssetStatsResponseDto { diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index c10924ff6c..f5ada315c1 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -34,3 +34,14 @@ export class RandomAssetsDto { @Type(() => Number) count?: number; } + +export enum TrashAction { + EMPTY_ALL = 'empty-all', + RESTORE_ALL = 'restore-all', +} + +export class AssetBulkDeleteDto extends BulkIdsDto { + @Optional() + @IsBoolean() + force?: boolean; +} diff --git a/server/src/domain/asset/dto/time-bucket.dto.ts b/server/src/domain/asset/dto/time-bucket.dto.ts index 7b1100d3ab..a21cd169a8 100644 --- a/server/src/domain/asset/dto/time-bucket.dto.ts +++ b/server/src/domain/asset/dto/time-bucket.dto.ts @@ -28,6 +28,11 @@ export class TimeBucketDto { @IsBoolean() @Transform(toBoolean) isFavorite?: boolean; + + @Optional() + @IsBoolean() + @Transform(toBoolean) + isTrashed?: boolean; } export class TimeBucketAssetDto extends TimeBucketDto { diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 6d28bac926..53454056ab 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -26,6 +26,7 @@ export class AssetResponseDto { updatedAt!: Date; isFavorite!: boolean; isArchived!: boolean; + isTrashed!: boolean; localDateTime!: Date; isOffline!: boolean; isExternal!: boolean; @@ -59,6 +60,7 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto { updatedAt: entity.updatedAt, isFavorite: entity.isFavorite, isArchived: entity.isArchived, + isTrashed: !!entity.deletedAt, duration: entity.duration ?? '0:00:00.00000', exifInfo: withExif ? (entity.exifInfo ? mapExif(entity.exifInfo) : undefined) : undefined, smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, diff --git a/server/src/domain/communication/communication.repository.ts b/server/src/domain/communication/communication.repository.ts index 1295214468..3201d07337 100644 --- a/server/src/domain/communication/communication.repository.ts +++ b/server/src/domain/communication/communication.repository.ts @@ -2,8 +2,10 @@ export const ICommunicationRepository = 'ICommunicationRepository'; export enum CommunicationEvent { UPLOAD_SUCCESS = 'on_upload_success', + CONFIG_UPDATE = 'on_config_update', } export interface ICommunicationRepository { send(event: CommunicationEvent, userId: string, data: any): void; + broadcast(event: CommunicationEvent, data: any): void; } diff --git a/server/src/domain/job/job.constants.ts b/server/src/domain/job/job.constants.ts index 3c422aaf7e..dbf8c2224b 100644 --- a/server/src/domain/job/job.constants.ts +++ b/server/src/domain/job/job.constants.ts @@ -41,6 +41,10 @@ export enum JobName { USER_DELETION = 'user-deletion', USER_DELETE_CHECK = 'user-delete-check', + // asset + ASSET_DELETION = 'asset-deletion', + ASSET_DELETION_CHECK = 'asset-deletion-check', + // storage template STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', @@ -99,6 +103,8 @@ export const JOBS_ASSET_PAGINATION_SIZE = 1000; export const JOBS_TO_QUEUE: Record = { // misc + [JobName.ASSET_DELETION]: QueueName.BACKGROUND_TASK, + [JobName.ASSET_DELETION_CHECK]: QueueName.BACKGROUND_TASK, [JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK, [JobName.USER_DELETION]: QueueName.BACKGROUND_TASK, [JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK, diff --git a/server/src/domain/job/job.interface.ts b/server/src/domain/job/job.interface.ts index b7377afa68..5ebce68fa1 100644 --- a/server/src/domain/job/job.interface.ts +++ b/server/src/domain/job/job.interface.ts @@ -12,6 +12,10 @@ export interface IEntityJob extends IBaseJob { source?: 'upload'; } +export interface IAssetDeletionJob extends IEntityJob { + fromExternal?: boolean; +} + export interface IOfflineLibraryFileJob extends IEntityJob { assetPath: string; } diff --git a/server/src/domain/job/job.repository.ts b/server/src/domain/job/job.repository.ts index 072d48af1b..12e865775f 100644 --- a/server/src/domain/job/job.repository.ts +++ b/server/src/domain/job/job.repository.ts @@ -1,6 +1,7 @@ import { JobName, QueueName } from './job.constants'; import { + IAssetDeletionJob, IAssetFaceJob, IBaseJob, IBulkEntityJob, @@ -82,6 +83,8 @@ export type JobItem = // Asset Deletion | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } + | { name: JobName.ASSET_DELETION; data: IAssetDeletionJob } + | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob } // Library Managment | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob } diff --git a/server/src/domain/job/job.service.spec.ts b/server/src/domain/job/job.service.spec.ts index 224f78836f..f5924b9982 100644 --- a/server/src/domain/job/job.service.spec.ts +++ b/server/src/domain/job/job.service.spec.ts @@ -48,6 +48,7 @@ describe(JobService.name, () => { await sut.handleNightlyJobs(); expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.ASSET_DELETION_CHECK }], [{ name: JobName.USER_DELETE_CHECK }], [{ name: JobName.PERSON_CLEANUP }], [{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }], diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index 3c152ee064..c342286519 100644 --- a/server/src/domain/job/job.service.ts +++ b/server/src/domain/job/job.service.ts @@ -140,6 +140,7 @@ export class JobService { } async handleNightlyJobs() { + await this.jobRepository.queue({ name: JobName.ASSET_DELETION_CHECK }); await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK }); await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP }); await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index 63dfc376b5..993b3499a3 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -1182,23 +1182,8 @@ describe(LibraryService.name, () => { expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.SEARCH_REMOVE_ASSET, - data: { - ids: [assetStub.image1.id], - }, - }, - ], - [ - { - name: JobName.DELETE_FILES, - data: { - files: [ - assetStub.image1.webpPath, - assetStub.image1.resizePath, - assetStub.image1.encodedVideoPath, - assetStub.image1.sidecarPath, - ], - }, + name: JobName.ASSET_DELETION, + data: { id: assetStub.image1.id, fromExternal: true }, }, ], ]); diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 604f37160d..b29446a54a 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -439,31 +439,17 @@ export class LibraryService { } private async deleteAssets(assetIds: string[]) { - // TODO: this should be refactored to a centralized asset deletion service for (const assetId of assetIds) { const asset = await this.assetRepository.getById(assetId); + if (!asset) { + continue; + } this.logger.debug(`Removing asset from library: ${asset.originalPath}`); - if (asset.faces) { - await Promise.all( - asset.faces.map(({ assetId, personId }) => - this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId } }), - ), - ); - } - - await this.assetRepository.remove(asset); - await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } }); - await this.jobRepository.queue({ - name: JobName.DELETE_FILES, - data: { files: [asset.webpPath, asset.resizePath, asset.encodedVideoPath, asset.sidecarPath] }, + name: JobName.ASSET_DELETION, + data: { id: asset.id, fromExternal: true }, }); - - // TODO refactor this to use cascades - if (asset.livePhotoVideoId && !assetIds.includes(asset.livePhotoVideoId)) { - assetIds.push(asset.livePhotoVideoId); - } } } } diff --git a/server/src/domain/server-info/server-info.dto.ts b/server/src/domain/server-info/server-info.dto.ts index 119aaf86ed..9bbda0f875 100644 --- a/server/src/domain/server-info/server-info.dto.ts +++ b/server/src/domain/server-info/server-info.dto.ts @@ -83,6 +83,8 @@ export class ServerConfigDto { oauthButtonText!: string; loginPageMessage!: string; mapTileUrl!: string; + @ApiProperty({ type: 'integer' }) + trashDays!: number; } export class ServerFeaturesDto implements FeatureFlags { @@ -90,6 +92,7 @@ export class ServerFeaturesDto implements FeatureFlags { configFile!: boolean; facialRecognition!: boolean; map!: boolean; + trash!: boolean; reverseGeocoding!: boolean; oauth!: boolean; oauthAutoLaunch!: boolean; diff --git a/server/src/domain/server-info/server-info.service.spec.ts b/server/src/domain/server-info/server-info.service.spec.ts index 7a1e8ebcee..b3db108829 100644 --- a/server/src/domain/server-info/server-info.service.spec.ts +++ b/server/src/domain/server-info/server-info.service.spec.ts @@ -159,6 +159,7 @@ describe(ServerInfoService.name, () => { sidecar: true, tagImage: true, configFile: false, + trash: true, }); expect(configMock.load).toHaveBeenCalled(); }); @@ -169,6 +170,7 @@ describe(ServerInfoService.name, () => { await expect(sut.getConfig()).resolves.toEqual({ loginPageMessage: '', oauthButtonText: 'Login with OAuth', + trashDays: 30, mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', }); expect(configMock.load).toHaveBeenCalled(); diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts index 0ef1e568f3..d54aab682f 100644 --- a/server/src/domain/server-info/server-info.service.ts +++ b/server/src/domain/server-info/server-info.service.ts @@ -66,6 +66,7 @@ export class ServerInfoService { return { loginPageMessage, mapTileUrl: config.map.tileUrl, + trashDays: config.trash.days, oauthButtonText: config.oauth.buttonText, }; } diff --git a/server/src/domain/system-config/dto/index.ts b/server/src/domain/system-config/dto/index.ts index 9eb2357964..4a94b4cc8b 100644 --- a/server/src/domain/system-config/dto/index.ts +++ b/server/src/domain/system-config/dto/index.ts @@ -3,4 +3,5 @@ export * from './system-config-oauth.dto'; export * from './system-config-password-login.dto'; export * from './system-config-storage-template.dto'; export * from './system-config-thumbnail.dto'; +export * from './system-config-trash.dto'; export * from './system-config.dto'; diff --git a/server/src/domain/system-config/dto/system-config-trash.dto.ts b/server/src/domain/system-config/dto/system-config-trash.dto.ts new file mode 100644 index 0000000000..bfbdb39415 --- /dev/null +++ b/server/src/domain/system-config/dto/system-config-trash.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsInt, Min } from 'class-validator'; + +export class SystemConfigTrashDto { + @IsBoolean() + enabled!: boolean; + + @IsInt() + @Min(0) + @Type(() => Number) + @ApiProperty({ type: 'integer' }) + days!: number; +} diff --git a/server/src/domain/system-config/dto/system-config.dto.ts b/server/src/domain/system-config/dto/system-config.dto.ts index b4099c2b22..2243bbe702 100644 --- a/server/src/domain/system-config/dto/system-config.dto.ts +++ b/server/src/domain/system-config/dto/system-config.dto.ts @@ -1,4 +1,4 @@ -import { SystemConfigThumbnailDto } from '@app/domain/system-config'; +import { SystemConfigThumbnailDto, SystemConfigTrashDto } from '@app/domain/system-config'; import { SystemConfig } from '@app/infra/entities'; import { Type } from 'class-transformer'; import { IsObject, ValidateNested } from 'class-validator'; @@ -56,6 +56,11 @@ export class SystemConfigDto implements SystemConfig { @ValidateNested() @IsObject() thumbnail!: SystemConfigThumbnailDto; + + @Type(() => SystemConfigTrashDto) + @ValidateNested() + @IsObject() + trash!: SystemConfigTrashDto; } export function mapConfig(config: SystemConfig): SystemConfigDto { diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index 0dc35cc103..07e31aa5b4 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -102,17 +102,19 @@ export const defaults = Object.freeze({ passwordLogin: { enabled: true, }, - storageTemplate: { template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, - thumbnail: { webpSize: 250, jpegSize: 1440, quality: 80, colorspace: Colorspace.P3, }, + trash: { + enabled: true, + days: 30, + }, }); export enum FeatureFlag { @@ -127,6 +129,7 @@ export enum FeatureFlag { OAUTH_AUTO_LAUNCH = 'oauthAutoLaunch', PASSWORD_LOGIN = 'passwordLogin', CONFIG_FILE = 'configFile', + TRASH = 'trash', } export type FeatureFlags = Record; @@ -186,6 +189,7 @@ export class SystemConfigCore { [FeatureFlag.REVERSE_GEOCODING]: config.reverseGeocoding.enabled, [FeatureFlag.SIDECAR]: true, [FeatureFlag.SEARCH]: process.env.TYPESENSE_ENABLED !== 'false', + [FeatureFlag.TRASH]: config.trash.enabled, // TODO: use these instead of `POST oauth/config` [FeatureFlag.OAUTH]: config.oauth.enabled, diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index e084da9ad3..8da7ae4aef 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -12,7 +12,8 @@ import { VideoCodec, } from '@app/infra/entities'; import { BadRequestException } from '@nestjs/common'; -import { newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test'; +import { newCommunicationRepositoryMock, newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test'; +import { ICommunicationRepository } from '..'; import { IJobRepository, JobName, QueueName } from '../job'; import { SystemConfigValidator, defaults } from './system-config.core'; import { ISystemConfigRepository } from './system-config.repository'; @@ -21,6 +22,7 @@ import { SystemConfigService } from './system-config.service'; const updates: SystemConfigEntity[] = [ { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, + { key: SystemConfigKey.TRASH_DAYS, value: 10 }, ]; const updatedConfig = Object.freeze({ @@ -110,18 +112,24 @@ const updatedConfig = Object.freeze({ quality: 80, colorspace: Colorspace.P3, }, + trash: { + enabled: true, + days: 10, + }, }); describe(SystemConfigService.name, () => { let sut: SystemConfigService; let configMock: jest.Mocked; + let communicationMock: jest.Mocked; let jobMock: jest.Mocked; beforeEach(async () => { delete process.env.IMMICH_CONFIG_FILE; configMock = newSystemConfigRepositoryMock(); + communicationMock = newCommunicationRepositoryMock(); jobMock = newJobRepositoryMock(); - sut = new SystemConfigService(configMock, jobMock); + sut = new SystemConfigService(configMock, communicationMock, jobMock); }); it('should work', () => { @@ -157,6 +165,7 @@ describe(SystemConfigService.name, () => { configMock.load.mockResolvedValue([ { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, + { key: SystemConfigKey.TRASH_DAYS, value: 10 }, ]); await expect(sut.getConfig()).resolves.toEqual(updatedConfig); @@ -164,7 +173,7 @@ describe(SystemConfigService.name, () => { it('should load the config from a file', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true } }; + const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true }, trash: { days: 10 } }; configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(partialConfig))); await expect(sut.getConfig()).resolves.toEqual(updatedConfig); diff --git a/server/src/domain/system-config/system-config.service.ts b/server/src/domain/system-config/system-config.service.ts index 3359175010..ece328ac43 100644 --- a/server/src/domain/system-config/system-config.service.ts +++ b/server/src/domain/system-config/system-config.service.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { ISystemConfigRepository } from '.'; +import { CommunicationEvent, ICommunicationRepository } from '../communication'; import { IJobRepository, JobName } from '../job'; import { SystemConfigDto, mapConfig } from './dto/system-config.dto'; import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto'; @@ -20,6 +21,7 @@ export class SystemConfigService { private core: SystemConfigCore; constructor( @Inject(ISystemConfigRepository) repository: ISystemConfigRepository, + @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, ) { this.core = new SystemConfigCore(repository); @@ -42,6 +44,7 @@ export class SystemConfigService { async updateConfig(dto: SystemConfigDto): Promise { const config = await this.core.updateConfig(dto); await this.jobRepository.queue({ name: JobName.SYSTEM_CONFIG_CHANGE }); + this.communicationRepository.broadcast(CommunicationEvent.CONFIG_UPDATE, {}); return mapConfig(config); } diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index 0bf46517c6..9a0e81f756 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -23,7 +23,6 @@ export interface AssetOwnerCheck extends AssetCheck { export interface IAssetRepository { get(id: string): Promise; create(asset: AssetCreate): Promise; - remove(asset: AssetEntity): Promise; getAllByUserId(userId: string, dto: AssetSearchDto): Promise; getAllByDeviceId(userId: string, deviceId: string): Promise; getById(assetId: string): Promise; @@ -111,6 +110,8 @@ export class AssetRepository implements IAssetRepository { person: true, }, }, + // We are specifically asking for this asset. Return it even if it is soft deleted + withDeleted: true, }); } @@ -135,6 +136,7 @@ export class AssetRepository implements IAssetRepository { order: { fileCreatedAt: 'DESC', }, + withDeleted: true, }); } @@ -147,6 +149,7 @@ export class AssetRepository implements IAssetRepository { }, library: true, }, + withDeleted: true, }); } @@ -154,10 +157,6 @@ export class AssetRepository implements IAssetRepository { return this.assetRepository.save(asset); } - async remove(asset: AssetEntity): Promise { - await this.assetRepository.remove(asset); - } - /** * Get assets by device's Id on the database * @param ownerId @@ -194,6 +193,7 @@ export class AssetRepository implements IAssetRepository { ownerId, checksum: In(checksums), }, + withDeleted: true, }); } diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index 8c7c17e1c1..ad6e160b35 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -2,7 +2,6 @@ import { AssetResponseDto, AuthUserDto } from '@app/domain'; import { Body, Controller, - Delete, Get, HttpCode, HttpStatus, @@ -27,7 +26,6 @@ import { AssetSearchDto } from './dto/asset-search.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto'; -import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeviceIdDto } from './dto/device-id.dto'; import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto'; import { SearchAssetDto } from './dto/search-asset.dto'; @@ -38,7 +36,6 @@ import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-a import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; -import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto'; interface UploadFiles { assetData: ImmichFile[]; @@ -192,14 +189,6 @@ export class AssetController { return this.assetService.getAssetById(authUser, id); } - @Delete('/') - deleteAsset( - @AuthUser() authUser: AuthUserDto, - @Body(ValidationPipe) dto: DeleteAssetDto, - ): Promise { - return this.assetService.deleteAll(authUser, dto); - } - /** * Check duplicated asset before uploading - for Web upload used */ diff --git a/server/src/immich/api-v1/asset/asset.core.ts b/server/src/immich/api-v1/asset/asset.core.ts index ac1b4e26fa..9f5691b223 100644 --- a/server/src/immich/api-v1/asset/asset.core.ts +++ b/server/src/immich/api-v1/asset/asset.core.ts @@ -30,6 +30,7 @@ export class AssetCore { fileCreatedAt: dto.fileCreatedAt, fileModifiedAt: dto.fileModifiedAt, localDateTime: dto.fileCreatedAt, + deletedAt: null, type: mimeTypes.assetType(file.originalPath), isFavorite: dto.isFavorite, diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index c6c1f2888e..a293a7875d 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -6,7 +6,6 @@ import { assetStub, authStub, fileStub, - libraryStub, newAccessRepositoryMock, newCryptoRepositoryMock, newJobRepositoryMock, @@ -98,7 +97,6 @@ describe('AssetService', () => { assetRepositoryMock = { get: jest.fn(), create: jest.fn(), - remove: jest.fn(), getAllByUserId: jest.fn(), getAllByDeviceId: jest.fn(), @@ -212,132 +210,6 @@ describe('AssetService', () => { expect(result).toEqual(assets.map((asset) => asset.deviceAssetId)); }); - describe('deleteAll', () => { - it('should return failed status when an asset is missing', async () => { - assetRepositoryMock.get.mockResolvedValue(null); - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); - - await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([ - { id: 'asset1', status: 'FAILED' }, - ]); - - expect(jobMock.queue).not.toHaveBeenCalled(); - }); - - it('should return failed status a delete fails', async () => { - assetRepositoryMock.get.mockResolvedValue({ - id: 'asset1', - library: libraryStub.uploadLibrary1, - } as AssetEntity); - assetRepositoryMock.remove.mockRejectedValue('delete failed'); - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); - - await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([ - { id: 'asset1', status: 'FAILED' }, - ]); - - expect(jobMock.queue).not.toHaveBeenCalled(); - }); - - it('should delete a live photo', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); - - await expect(sut.deleteAll(authStub.user1, { ids: [assetStub.livePhotoStillAsset.id] })).resolves.toEqual([ - { id: assetStub.livePhotoStillAsset.id, status: 'SUCCESS' }, - { id: assetStub.livePhotoMotionAsset.id, status: 'SUCCESS' }, - ]); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.DELETE_FILES, - data: { - files: [ - 'fake_path/asset_1.jpeg', - undefined, - undefined, - undefined, - undefined, - 'fake_path/asset_1.mp4', - undefined, - undefined, - undefined, - undefined, - ], - }, - }); - }); - - it('should delete a batch of assets', async () => { - const asset1 = { - id: 'asset1', - originalPath: 'original-path-1', - resizePath: 'resize-path-1', - webpPath: 'web-path-1', - library: libraryStub.uploadLibrary1, - }; - - const asset2 = { - id: 'asset2', - originalPath: 'original-path-2', - resizePath: 'resize-path-2', - webpPath: 'web-path-2', - encodedVideoPath: 'encoded-video-path-2', - library: libraryStub.uploadLibrary1, - }; - - // Can't be deleted since it's external - const asset3 = { - id: 'asset3', - originalPath: 'original-path-3', - resizePath: 'resize-path-3', - webpPath: 'web-path-3', - encodedVideoPath: 'encoded-video-path-2', - library: libraryStub.externalLibrary1, - }; - - when(assetRepositoryMock.get) - .calledWith(asset1.id) - .mockResolvedValue(asset1 as AssetEntity); - when(assetRepositoryMock.get) - .calledWith(asset2.id) - .mockResolvedValue(asset2 as AssetEntity); - when(assetRepositoryMock.get) - .calledWith(asset3.id) - .mockResolvedValue(asset3 as AssetEntity); - - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); - - await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2', 'asset3'] })).resolves.toEqual([ - { id: 'asset1', status: 'SUCCESS' }, - { id: 'asset2', status: 'SUCCESS' }, - { id: 'asset3', status: 'FAILED' }, - ]); - - expect(jobMock.queue.mock.calls).toEqual([ - [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: ['asset1'] } }], - [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: ['asset2'] } }], - [ - { - name: JobName.DELETE_FILES, - data: { - files: [ - 'original-path-1', - 'web-path-1', - 'resize-path-1', - undefined, - undefined, - 'original-path-2', - 'web-path-2', - 'resize-path-2', - 'encoded-video-path-2', - undefined, - ], - }, - }, - ], - ]); - }); - }); - describe('bulkUploadCheck', () => { it('should accept hex and base64 checksums', async () => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 5a32229dd0..cbb89fcb08 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -37,7 +37,6 @@ import { AssetSearchDto } from './dto/asset-search.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto'; -import { DeleteAssetDto } from './dto/delete-asset.dto'; import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto'; import { SearchAssetDto } from './dto/search-asset.dto'; import { SearchPropertiesDto } from './dto/search-properties.dto'; @@ -52,7 +51,6 @@ import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-a import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; -import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; @Injectable() export class AssetService { @@ -246,66 +244,6 @@ export class AssetService { await this.sendFile(res, filepath); } - public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise { - const deleteQueue: Array = []; - const result: DeleteAssetResponseDto[] = []; - - const ids = dto.ids.slice(); - for (const id of ids) { - const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_DELETE, id); - if (!hasAccess) { - result.push({ id, status: DeleteAssetStatusEnum.FAILED }); - continue; - } - - const asset = await this._assetRepository.get(id); - if (!asset || !asset.library || asset.library.type === LibraryType.EXTERNAL) { - // We don't allow deletions assets belong to an external library - result.push({ id, status: DeleteAssetStatusEnum.FAILED }); - continue; - } - - try { - if (asset.faces) { - await Promise.all( - asset.faces.map(({ assetId, personId }) => - this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId } }), - ), - ); - } - - await this._assetRepository.remove(asset); - await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } }); - - result.push({ id, status: DeleteAssetStatusEnum.SUCCESS }); - - if (!asset.isReadOnly) { - deleteQueue.push( - asset.originalPath, - asset.webpPath, - asset.resizePath, - asset.encodedVideoPath, - asset.sidecarPath, - ); - } - - // TODO refactor this to use cascades - if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) { - ids.push(asset.livePhotoVideoId); - } - } catch (error) { - this.logger.error(`Error deleting asset ${id}`, error); - result.push({ id, status: DeleteAssetStatusEnum.FAILED }); - } - } - - if (deleteQueue.length > 0) { - await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: deleteQueue } }); - } - - return result; - } - async getAssetSearchTerm(authUser: AuthUserDto): Promise { const possibleSearchTerm = new Set(); diff --git a/server/src/immich/api-v1/asset/dto/delete-asset.dto.ts b/server/src/immich/api-v1/asset/dto/delete-asset.dto.ts deleted file mode 100644 index 12cb89a9ba..0000000000 --- a/server/src/immich/api-v1/asset/dto/delete-asset.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty } from 'class-validator'; - -export class DeleteAssetDto { - @IsNotEmpty() - @ApiProperty({ - isArray: true, - type: String, - title: 'Array of asset IDs to delete', - example: [ - 'bf973405-3f2a-48d2-a687-2ed4167164be', - 'dd41870b-5d00-46d2-924e-1d8489a0aa0f', - 'fad77c3f-deef-4e7e-9608-14c1aa4e559a', - ], - }) - ids!: string[]; -} diff --git a/server/src/immich/api-v1/asset/response-dto/delete-asset-response.dto.ts b/server/src/immich/api-v1/asset/response-dto/delete-asset-response.dto.ts deleted file mode 100644 index 86f6afdf18..0000000000 --- a/server/src/immich/api-v1/asset/response-dto/delete-asset-response.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; - -export enum DeleteAssetStatusEnum { - SUCCESS = 'SUCCESS', - FAILED = 'FAILED', -} - -export class DeleteAssetResponseDto { - id!: string; - - @ApiProperty({ type: 'string', enum: DeleteAssetStatusEnum, enumName: 'DeleteAssetStatus' }) - status!: DeleteAssetStatusEnum; -} diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index f3bd9747ed..4780906ada 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -1,4 +1,5 @@ import { + AssetBulkDeleteDto, AssetBulkUpdateDto, AssetIdsDto, AssetJobsDto, @@ -7,6 +8,7 @@ import { AssetStatsDto, AssetStatsResponseDto, AuthUserDto, + BulkIdsDto, DownloadInfoDto, DownloadResponseDto, MapMarkerDto, @@ -17,9 +19,22 @@ import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto, + TrashAction, UpdateAssetDto as UpdateDto, } from '@app/domain'; -import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put, Query, StreamableFile } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, + Query, + StreamableFile, +} from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { AuthUser, Authenticated, SharedLinkRoute } from '../app.guard'; import { UseValidation, asStreamableFile } from '../app.utils'; @@ -98,6 +113,30 @@ export class AssetController { return this.service.updateAll(authUser, dto); } + @Delete() + @HttpCode(HttpStatus.NO_CONTENT) + deleteAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkDeleteDto): Promise { + return this.service.deleteAll(authUser, dto); + } + + @Post('restore') + @HttpCode(HttpStatus.NO_CONTENT) + restoreAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: BulkIdsDto): Promise { + return this.service.restoreAll(authUser, dto); + } + + @Post('trash/empty') + @HttpCode(HttpStatus.NO_CONTENT) + emptyTrash(@AuthUser() authUser: AuthUserDto): Promise { + return this.service.handleTrashAction(authUser, TrashAction.EMPTY_ALL); + } + + @Post('trash/restore') + @HttpCode(HttpStatus.NO_CONTENT) + restoreTrash(@AuthUser() authUser: AuthUserDto): Promise { + return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL); + } + @Put(':id') updateAsset( @AuthUser() authUser: AuthUserDto, diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index 1b5fd5ca08..cb01c63ab7 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -1,6 +1,7 @@ import { Column, CreateDateColumn, + DeleteDateColumn, Entity, Index, JoinColumn, @@ -77,6 +78,9 @@ export class AssetEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; + @DeleteDateColumn({ type: 'timestamptz', nullable: true }) + deletedAt!: Date | null; + @Column({ type: 'timestamptz' }) fileCreatedAt!: Date; diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index e8d9f5f1a3..47b1f69fd9 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -87,6 +87,9 @@ export enum SystemConfigKey { THUMBNAIL_JPEG_SIZE = 'thumbnail.jpegSize', THUMBNAIL_QUALITY = 'thumbnail.quality', THUMBNAIL_COLORSPACE = 'thumbnail.colorspace', + + TRASH_ENABLED = 'trash.enabled', + TRASH_DAYS = 'trash.days', } export enum TranscodePolicy { @@ -214,4 +217,8 @@ export interface SystemConfig { quality: number; colorspace: Colorspace; }; + trash: { + enabled: boolean; + days: number; + }; } diff --git a/server/src/infra/migrations/1694204416744-AddAssetDeletedAtColumn.ts b/server/src/infra/migrations/1694204416744-AddAssetDeletedAtColumn.ts new file mode 100644 index 0000000000..3b213b9f04 --- /dev/null +++ b/server/src/infra/migrations/1694204416744-AddAssetDeletedAtColumn.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAssetDeletedAtColumn1694204416744 implements MigrationInterface { + name = 'AddAssetDeletedAtColumn1694204416744' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ADD "deletedAt" TIMESTAMP WITH TIME ZONE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "deletedAt"`); + } + +} diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index 94eecbcb8f..24b8e8ef40 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -86,6 +86,7 @@ export class AccessRepository implements IAccessRepository { id: assetId, ownerId: userId, }, + withDeleted: true, }); }, diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index ec7e69aee2..4e91f917c5 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -19,7 +19,7 @@ import { import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DateTime } from 'luxon'; -import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm'; +import { And, FindOptionsRelations, FindOptionsWhere, In, IsNull, LessThan, Not, Repository } from 'typeorm'; import { AssetEntity, AssetType, ExifEntity } from '../entities'; import OptionalBetween from '../utils/optional-between.util'; import { paginate } from '../utils/pagination.util'; @@ -109,6 +109,7 @@ export class AssetRepository implements IAssetRepository { person: true, }, }, + withDeleted: true, }); } @@ -130,15 +131,17 @@ export class AssetRepository implements IAssetRepository { }); } - getByUserId(pagination: PaginationOptions, userId: string): Paginated { + getByUserId(pagination: PaginationOptions, userId: string, options: AssetSearchOptions = {}): Paginated { return paginate(this.repository, pagination, { where: { ownerId: userId, - isVisible: true, + isVisible: options.isVisible, + deletedAt: options.trashedBefore ? And(Not(IsNull()), LessThan(options.trashedBefore)) : undefined, }, relations: { exifInfo: true, }, + withDeleted: !!options.trashedBefore, }); } @@ -154,32 +157,12 @@ export class AssetRepository implements IAssetRepository { }); } - getById(assetId: string): Promise { - return this.repository.findOneOrFail({ - where: { - id: assetId, - }, - relations: { - exifInfo: true, - tags: true, - sharedLinks: true, - smartInfo: true, - faces: { - person: true, - }, - }, - }); - } - - remove(asset: AssetEntity): Promise { - return this.repository.remove(asset); - } - getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { return paginate(this.repository, pagination, { where: { isVisible: options.isVisible, type: options.type, + deletedAt: options.trashedBefore ? And(Not(IsNull()), LessThan(options.trashedBefore)) : undefined, }, relations: { exifInfo: true, @@ -189,6 +172,7 @@ export class AssetRepository implements IAssetRepository { person: true, }, }, + withDeleted: !!options.trashedBefore, order: { // Ensures correct order when paginating createdAt: options.order ?? 'ASC', @@ -196,10 +180,32 @@ export class AssetRepository implements IAssetRepository { }); } + getById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: { + faces: { + person: true, + }, + library: true, + }, + // We are specifically asking for this asset. Return it even if it is soft deleted + withDeleted: true, + }); + } + async updateAll(ids: string[], options: Partial): Promise { await this.repository.update({ id: In(ids) }, options); } + async softDeleteAll(ids: string[]): Promise { + await this.repository.softDelete({ id: In(ids), isExternal: false }); + } + + async restoreAll(ids: string[]): Promise { + await this.repository.restore({ id: In(ids) }); + } + async save(asset: Partial): Promise { const { id } = await this.repository.save(asset); return this.repository.findOneOrFail({ @@ -213,9 +219,14 @@ export class AssetRepository implements IAssetRepository { person: true, }, }, + withDeleted: true, }); } + async remove(asset: AssetEntity): Promise { + await this.repository.remove(asset); + } + getByChecksum(userId: string, checksum: Buffer): Promise { return this.repository.findOne({ where: { ownerId: userId, checksum } }); } @@ -424,7 +435,7 @@ export class AssetRepository implements IAssetRepository { .andWhere('asset.isVisible = true') .groupBy('asset.type'); - const { isArchived, isFavorite } = options; + const { isArchived, isFavorite, isTrashed } = options; if (isArchived !== undefined) { builder = builder.andWhere(`asset.isArchived = :isArchived`, { isArchived }); } @@ -433,6 +444,10 @@ export class AssetRepository implements IAssetRepository { builder = builder.andWhere(`asset.isFavorite = :isFavorite`, { isFavorite }); } + if (isTrashed !== undefined) { + builder = builder.withDeleted().andWhere(`asset.deletedAt is not null`); + } + const items = await builder.getRawMany(); const result: AssetStats = { @@ -481,7 +496,7 @@ export class AssetRepository implements IAssetRepository { } private getBuilder(options: TimeBucketOptions) { - const { isArchived, isFavorite, albumId, personId, userId } = options; + const { isArchived, isFavorite, isTrashed, albumId, personId, userId } = options; let builder = this.repository .createQueryBuilder('asset') @@ -504,6 +519,10 @@ export class AssetRepository implements IAssetRepository { builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite }); } + if (isTrashed !== undefined) { + builder = builder.andWhere(`asset.deletedAt ${isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); + } + if (personId !== undefined) { builder = builder .innerJoin('asset.faces', 'faces') diff --git a/server/src/infra/repositories/communication.repository.ts b/server/src/infra/repositories/communication.repository.ts index e2158c9fc0..5fb11fd2ea 100644 --- a/server/src/infra/repositories/communication.repository.ts +++ b/server/src/infra/repositories/communication.repository.ts @@ -9,4 +9,8 @@ export class CommunicationRepository { send(event: CommunicationEvent, userId: string, data: any) { this.ws.server.to(userId).emit(event, JSON.stringify(data)); } + + broadcast(event: CommunicationEvent, data: any) { + this.ws.server.emit(event, data); + } } diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index a1a68e62a3..c444ee694f 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -67,6 +67,7 @@ export class PersonRepository implements IPersonRepository { .createQueryBuilder('person') .leftJoin('person.faces', 'face') .where('person.ownerId = :userId', { userId }) + .innerJoin('face.asset', 'asset') .orderBy('person.isHidden', 'ASC') .addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC') .addOrderBy('COUNT(face.assetId)', 'DESC') diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index eb3c57d4ba..248d5a7fb5 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -1,4 +1,5 @@ import { + AssetService, AuditService, IDeleteFilesJob, JobName, @@ -23,6 +24,7 @@ export class AppService { constructor( private jobService: JobService, + private assetService: AssetService, private mediaService: MediaService, private metadataService: MetadataService, private personService: PersonService, @@ -38,6 +40,8 @@ export class AppService { async init() { await this.jobService.registerHandlers({ + [JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data), + [JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(), [JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data), [JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(), [JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(), diff --git a/server/test/e2e/server-info.e2e-spec.ts b/server/test/e2e/server-info.e2e-spec.ts index c686b78be2..cd2814af3f 100644 --- a/server/test/e2e/server-info.e2e-spec.ts +++ b/server/test/e2e/server-info.e2e-spec.ts @@ -92,6 +92,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { search: false, sidecar: true, tagImage: true, + trash: true, }); }); }); @@ -104,6 +105,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { loginPageMessage: '', oauthButtonText: 'Login with OAuth', mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + trashDays: 30, }); }); }); diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 030dabbe3f..77d0b6bb01 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -35,6 +35,7 @@ export const assetStub = { faces: [], sidecarPath: null, isReadOnly: false, + deletedAt: null, isOffline: false, isExternal: false, libraryId: 'library-id', @@ -77,6 +78,7 @@ export const assetStub = { exifInfo: { fileSizeInByte: 123_000, } as ExifEntity, + deletedAt: null, }), noThumbhash: Object.freeze({ id: 'asset-id', @@ -112,6 +114,7 @@ export const assetStub = { originalFileName: 'asset-id.ext', faces: [], sidecarPath: null, + deletedAt: null, }), image: Object.freeze({ id: 'asset-id', @@ -146,6 +149,7 @@ export const assetStub = { sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], + deletedAt: null, sidecarPath: null, exifInfo: { fileSizeInByte: 5_000, @@ -179,11 +183,12 @@ export const assetStub = { livePhotoVideoId: null, isOffline: false, libraryId: 'library-id', - library: libraryStub.uploadLibrary1, + library: libraryStub.externalLibrary1, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], + deletedAt: null, sidecarPath: null, exifInfo: { fileSizeInByte: 5_000, @@ -226,6 +231,7 @@ export const assetStub = { exifInfo: { fileSizeInByte: 5_000, } as ExifEntity, + deletedAt: null, }), image1: Object.freeze({ id: 'asset-id-1', @@ -244,6 +250,7 @@ export const assetStub = { encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), + deletedAt: null, localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, isArchived: false, @@ -302,6 +309,7 @@ export const assetStub = { exifInfo: { fileSizeInByte: 5_000, } as ExifEntity, + deletedAt: null, }), video: Object.freeze({ id: 'asset-id', @@ -340,6 +348,7 @@ export const assetStub = { exifInfo: { fileSizeInByte: 100_000, } as ExifEntity, + deletedAt: null, }), livePhotoMotionAsset: Object.freeze({ id: 'live-photo-motion-asset', @@ -411,6 +420,7 @@ export const assetStub = { longitude: 100, fileSizeInByte: 23_456, } as ExifEntity, + deletedAt: null, }), sidecar: Object.freeze({ id: 'asset-id', @@ -446,5 +456,12 @@ export const assetStub = { originalFileName: 'asset-id.ext', faces: [], sidecarPath: '/original/path.ext.xmp', + deletedAt: null, + }), + readOnly: Object.freeze({ + id: 'read-only-asset', + isReadOnly: true, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 601c41bc17..a5d180abe3 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -69,6 +69,7 @@ const assetResponse: AssetResponseDto = { tags: [], people: [], checksum: 'ZmlsZSBoYXNo', + isTrashed: false, libraryId: 'library-id', }; @@ -235,6 +236,7 @@ export const sharedLinkStub = { sharedLinks: [], faces: [], sidecarPath: null, + deletedAt: null, }, ], }, diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 5af26cad71..57020001cb 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -9,6 +9,7 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getByIds: jest.fn().mockResolvedValue([]), getByAlbumId: jest.fn(), getByUserId: jest.fn(), + getById: jest.fn(), getWithout: jest.fn(), getByChecksum: jest.fn(), getWith: jest.fn(), @@ -18,15 +19,16 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }), updateAll: jest.fn(), getByLibraryId: jest.fn(), - getById: jest.fn(), getByLibraryIdAndOriginalPath: jest.fn(), deleteAll: jest.fn(), save: jest.fn(), + remove: jest.fn(), findLivePhotoMatch: jest.fn(), getMapMarkers: jest.fn(), getStatistics: jest.fn(), getByTimeBucket: jest.fn(), getTimeBuckets: jest.fn(), - remove: jest.fn(), + restoreAll: jest.fn(), + softDeleteAll: jest.fn(), }; }; diff --git a/server/test/repositories/communication.repository.mock.ts b/server/test/repositories/communication.repository.mock.ts index d4c2f8c8fa..d8374e8b27 100644 --- a/server/test/repositories/communication.repository.mock.ts +++ b/server/test/repositories/communication.repository.mock.ts @@ -3,5 +3,6 @@ import { ICommunicationRepository } from '@app/domain'; export const newCommunicationRepositoryMock = (): jest.Mocked => { return { send: jest.fn(), + broadcast: jest.fn(), }; }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index c71b4705db..405bcd1d5a 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -356,6 +356,25 @@ export interface AllJobStatusResponseDto { */ 'videoConversion': JobStatusDto; } +/** + * + * @export + * @interface AssetBulkDeleteDto + */ +export interface AssetBulkDeleteDto { + /** + * + * @type {boolean} + * @memberof AssetBulkDeleteDto + */ + 'force'?: boolean; + /** + * + * @type {Array} + * @memberof AssetBulkDeleteDto + */ + 'ids': Array; +} /** * * @export @@ -657,6 +676,12 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'isReadOnly': boolean; + /** + * + * @type {boolean} + * @memberof AssetResponseDto + */ + 'isTrashed': boolean; /** * * @type {string} @@ -1357,54 +1382,6 @@ export interface CuratedObjectsResponseDto { */ 'resizePath': string; } -/** - * - * @export - * @interface DeleteAssetDto - */ -export interface DeleteAssetDto { - /** - * - * @type {Array} - * @memberof DeleteAssetDto - */ - 'ids': Array; -} -/** - * - * @export - * @interface DeleteAssetResponseDto - */ -export interface DeleteAssetResponseDto { - /** - * - * @type {string} - * @memberof DeleteAssetResponseDto - */ - 'id': string; - /** - * - * @type {DeleteAssetStatus} - * @memberof DeleteAssetResponseDto - */ - 'status': DeleteAssetStatus; -} - - -/** - * - * @export - * @enum {string} - */ - -export const DeleteAssetStatus = { - Success: 'SUCCESS', - Failed: 'FAILED' -} as const; - -export type DeleteAssetStatus = typeof DeleteAssetStatus[keyof typeof DeleteAssetStatus]; - - /** * * @export @@ -2623,6 +2600,12 @@ export interface ServerConfigDto { * @memberof ServerConfigDto */ 'oauthButtonText': string; + /** + * + * @type {number} + * @memberof ServerConfigDto + */ + 'trashDays': number; } /** * @@ -2696,6 +2679,12 @@ export interface ServerFeaturesDto { * @memberof ServerFeaturesDto */ 'tagImage': boolean; + /** + * + * @type {boolean} + * @memberof ServerFeaturesDto + */ + 'trash': boolean; } /** * @@ -3139,6 +3128,12 @@ export interface SystemConfigDto { * @memberof SystemConfigDto */ 'thumbnail': SystemConfigThumbnailDto; + /** + * + * @type {SystemConfigTrashDto} + * @memberof SystemConfigDto + */ + 'trash': SystemConfigTrashDto; } /** * @@ -3594,6 +3589,25 @@ export interface SystemConfigThumbnailDto { } +/** + * + * @export + * @interface SystemConfigTrashDto + */ +export interface SystemConfigTrashDto { + /** + * + * @type {number} + * @memberof SystemConfigTrashDto + */ + 'days': number; + /** + * + * @type {boolean} + * @memberof SystemConfigTrashDto + */ + 'enabled': boolean; +} /** * * @export @@ -5682,13 +5696,13 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }, /** * - * @param {DeleteAssetDto} deleteAssetDto + * @param {AssetBulkDeleteDto} assetBulkDeleteDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - deleteAsset: async (deleteAssetDto: DeleteAssetDto, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'deleteAssetDto' is not null or undefined - assertParamExists('deleteAsset', 'deleteAssetDto', deleteAssetDto) + deleteAssets: async (assetBulkDeleteDto: AssetBulkDeleteDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'assetBulkDeleteDto' is not null or undefined + assertParamExists('deleteAssets', 'assetBulkDeleteDto', assetBulkDeleteDto) const localVarPath = `/asset`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -5717,7 +5731,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(deleteAssetDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(assetBulkDeleteDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -5811,6 +5825,44 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + emptyTrash: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/trash/empty`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -5979,10 +6031,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * * @param {boolean} [isArchived] * @param {boolean} [isFavorite] + * @param {boolean} [isTrashed] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, options: AxiosRequestConfig = {}): Promise => { + getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, options: AxiosRequestConfig = {}): Promise => { const localVarPath = `/asset/statistics`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -6012,6 +6065,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['isFavorite'] = isFavorite; } + if (isTrashed !== undefined) { + localVarQueryParameter['isTrashed'] = isTrashed; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -6084,11 +6141,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {string} [personId] * @param {boolean} [isArchived] * @param {boolean} [isFavorite] + * @param {boolean} [isTrashed] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { + getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'size' is not null or undefined assertParamExists('getByTimeBucket', 'size', size) // verify required parameter 'timeBucket' is not null or undefined @@ -6138,6 +6196,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['isFavorite'] = isFavorite; } + if (isTrashed !== undefined) { + localVarQueryParameter['isTrashed'] = isTrashed; + } + if (timeBucket !== undefined) { localVarQueryParameter['timeBucket'] = timeBucket; } @@ -6447,11 +6509,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {string} [personId] * @param {boolean} [isArchived] * @param {boolean} [isFavorite] + * @param {boolean} [isTrashed] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { + getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'size' is not null or undefined assertParamExists('getTimeBuckets', 'size', size) const localVarPath = `/asset/time-buckets`; @@ -6499,6 +6562,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['isFavorite'] = isFavorite; } + if (isTrashed !== undefined) { + localVarQueryParameter['isTrashed'] = isTrashed; + } + if (key !== undefined) { localVarQueryParameter['key'] = key; } @@ -6600,6 +6667,88 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {BulkIdsDto} bulkIdsDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + restoreAssets: async (bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'bulkIdsDto' is not null or undefined + assertParamExists('restoreAssets', 'bulkIdsDto', bulkIdsDto) + const localVarPath = `/asset/restore`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + restoreTrash: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/trash/restore`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {AssetJobsDto} assetJobsDto @@ -7014,12 +7163,12 @@ export const AssetApiFp = function(configuration?: Configuration) { }, /** * - * @param {DeleteAssetDto} deleteAssetDto + * @param {AssetBulkDeleteDto} assetBulkDeleteDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async deleteAsset(deleteAssetDto: DeleteAssetDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAsset(deleteAssetDto, options); + async deleteAssets(assetBulkDeleteDto: AssetBulkDeleteDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAssets(assetBulkDeleteDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -7044,6 +7193,15 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async emptyTrash(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.emptyTrash(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * Get all AssetEntity belong to the user * @param {string} [userId] @@ -7083,11 +7241,12 @@ export const AssetApiFp = function(configuration?: Configuration) { * * @param {boolean} [isArchived] * @param {boolean} [isFavorite] + * @param {boolean} [isTrashed] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAssetStats(isArchived?: boolean, isFavorite?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, options); + async getAssetStats(isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, isTrashed, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -7111,12 +7270,13 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {string} [personId] * @param {boolean} [isArchived] * @param {boolean} [isFavorite] + * @param {boolean} [isTrashed] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, key, options); + async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -7190,12 +7350,13 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {string} [personId] * @param {boolean} [isArchived] * @param {boolean} [isFavorite] + * @param {boolean} [isTrashed] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key, options); + async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -7218,6 +7379,25 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {BulkIdsDto} bulkIdsDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async restoreAssets(bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.restoreAssets(bulkIdsDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async restoreTrash(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.restoreTrash(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {AssetJobsDto} assetJobsDto @@ -7336,12 +7516,12 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath }, /** * - * @param {AssetApiDeleteAssetRequest} requestParameters Request parameters. + * @param {AssetApiDeleteAssetsRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - deleteAsset(requestParameters: AssetApiDeleteAssetRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(axios, basePath)); + deleteAssets(requestParameters: AssetApiDeleteAssetsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.deleteAssets(requestParameters.assetBulkDeleteDto, options).then((request) => request(axios, basePath)); }, /** * @@ -7361,6 +7541,14 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath downloadFile(requestParameters: AssetApiDownloadFileRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + emptyTrash(options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.emptyTrash(options).then((request) => request(axios, basePath)); + }, /** * Get all AssetEntity belong to the user * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters. @@ -7394,7 +7582,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(axios, basePath)); + return localVarFp.getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, options).then((request) => request(axios, basePath)); }, /** * @@ -7412,7 +7600,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(axios, basePath)); + return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * @@ -7473,7 +7661,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(axios, basePath)); + return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * Get all asset of a device that are in the database, ID only. @@ -7493,6 +7681,23 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiRestoreAssetsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + restoreAssets(requestParameters: AssetApiRestoreAssetsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.restoreAssets(requestParameters.bulkIdsDto, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + restoreTrash(options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.restoreTrash(options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters. @@ -7600,17 +7805,17 @@ export interface AssetApiCheckExistingAssetsRequest { } /** - * Request parameters for deleteAsset operation in AssetApi. + * Request parameters for deleteAssets operation in AssetApi. * @export - * @interface AssetApiDeleteAssetRequest + * @interface AssetApiDeleteAssetsRequest */ -export interface AssetApiDeleteAssetRequest { +export interface AssetApiDeleteAssetsRequest { /** * - * @type {DeleteAssetDto} - * @memberof AssetApiDeleteAsset + * @type {AssetBulkDeleteDto} + * @memberof AssetApiDeleteAssets */ - readonly deleteAssetDto: DeleteAssetDto + readonly assetBulkDeleteDto: AssetBulkDeleteDto } /** @@ -7744,6 +7949,13 @@ export interface AssetApiGetAssetStatsRequest { * @memberof AssetApiGetAssetStats */ readonly isFavorite?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiGetAssetStats + */ + readonly isTrashed?: boolean } /** @@ -7829,6 +8041,13 @@ export interface AssetApiGetByTimeBucketRequest { */ readonly isFavorite?: boolean + /** + * + * @type {boolean} + * @memberof AssetApiGetByTimeBucket + */ + readonly isTrashed?: boolean + /** * * @type {string} @@ -7976,6 +8195,13 @@ export interface AssetApiGetTimeBucketsRequest { */ readonly isFavorite?: boolean + /** + * + * @type {boolean} + * @memberof AssetApiGetTimeBuckets + */ + readonly isTrashed?: boolean + /** * * @type {string} @@ -8012,6 +8238,20 @@ export interface AssetApiImportFileRequest { readonly importAssetDto: ImportAssetDto } +/** + * Request parameters for restoreAssets operation in AssetApi. + * @export + * @interface AssetApiRestoreAssetsRequest + */ +export interface AssetApiRestoreAssetsRequest { + /** + * + * @type {BulkIdsDto} + * @memberof AssetApiRestoreAssets + */ + readonly bulkIdsDto: BulkIdsDto +} + /** * Request parameters for runAssetJobs operation in AssetApi. * @export @@ -8271,13 +8511,13 @@ export class AssetApi extends BaseAPI { /** * - * @param {AssetApiDeleteAssetRequest} requestParameters Request parameters. + * @param {AssetApiDeleteAssetsRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof AssetApi */ - public deleteAsset(requestParameters: AssetApiDeleteAssetRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(this.axios, this.basePath)); + public deleteAssets(requestParameters: AssetApiDeleteAssetsRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).deleteAssets(requestParameters.assetBulkDeleteDto, options).then((request) => request(this.axios, this.basePath)); } /** @@ -8302,6 +8542,16 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public emptyTrash(options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).emptyTrash(options).then((request) => request(this.axios, this.basePath)); + } + /** * Get all AssetEntity belong to the user * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters. @@ -8342,7 +8592,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, options).then((request) => request(this.axios, this.basePath)); } /** @@ -8364,7 +8614,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** @@ -8439,7 +8689,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** @@ -8464,6 +8714,27 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiRestoreAssetsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public restoreAssets(requestParameters: AssetApiRestoreAssetsRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).restoreAssets(requestParameters.bulkIdsDto, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public restoreTrash(options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).restoreTrash(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters. diff --git a/web/src/lib/assets/empty-3.svg b/web/src/lib/assets/empty-3.svg new file mode 100644 index 0000000000..212b716de1 --- /dev/null +++ b/web/src/lib/assets/empty-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte index df9059fced..8af6e748d6 100644 --- a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte +++ b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte @@ -12,6 +12,7 @@ import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; import SettingSwitch from '../setting-switch.svelte'; import SettingSelect from '../setting-select.svelte'; + import { loadConfig } from '$lib/stores/server-config.store'; export let config: SystemConfigDto; // this is the config that is being edited export let disabled = false; @@ -47,6 +48,9 @@ savedConfig = cloneDeep(updated); notificationController.show({ message: 'Settings saved', type: NotificationType.Info }); + // TODO: Use websockets to reload feature params instead once websocket for client is merged + // Reload feature params in the background + loadConfig(); } catch (error) { handleError(error, 'Unable to save settings'); } diff --git a/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte b/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte new file mode 100644 index 0000000000..7e586ce13d --- /dev/null +++ b/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte @@ -0,0 +1,107 @@ + + +
+ {#await getConfigs() then} +
+
+
+ + +
+ + + + +
+
+
+ {/await} +
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 446075a489..5f93191fbd 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -26,13 +26,17 @@ import type { AssetStore } from '$lib/stores/assets.store'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import Close from 'svelte-material-icons/Close.svelte'; + import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte'; import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; + import { featureFlags } from '$lib/stores/server-config.store'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; export let showNavigation = true; export let sharedLink: SharedLinkResponseDto | undefined = undefined; + $: isTrashEnabled = $featureFlags.trash; + export let force = false; const dispatch = createEventDispatcher<{ archived: AssetResponseDto; @@ -117,7 +121,7 @@ } return; case 'Delete': - isShowDeleteConfirmation = true; + trashOrDelete(); return; case 'Escape': if (isShowDeleteConfirmation) { @@ -169,27 +173,43 @@ $isShowDetail = !$isShowDetail; }; - const deleteAsset = async () => { + $: trashOrDelete = !(force || !isTrashEnabled) + ? trashAsset + : () => { + isShowDeleteConfirmation = true; + }; + + const trashAsset = async () => { try { - const { data: deletedAssets } = await api.assetApi.deleteAsset({ - deleteAssetDto: { - ids: [asset.id], - }, - }); + await api.assetApi.deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } }); await navigateAssetForward(); - for (const asset of deletedAssets) { - if (asset.status == 'SUCCESS') { - assetStore?.removeAsset(asset.id); - } - } - } catch (e) { + assetStore?.removeAsset(asset.id); + notificationController.show({ - type: NotificationType.Error, - message: 'Error deleting this asset, check console for more details', + message: 'Moved to trash', + type: NotificationType.Info, }); - console.error('Error deleteAsset', e); + } catch (e) { + handleError(e, 'Unable to trash asset'); + } + }; + + const deleteAsset = async () => { + try { + await api.assetApi.deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } }); + + await navigateAssetForward(); + + assetStore?.removeAsset(asset.id); + + notificationController.show({ + message: 'Permanently deleted asset', + type: NotificationType.Info, + }); + } catch (e) { + handleError(e, 'Unable to delete asset'); } finally { isShowDeleteConfirmation = false; } @@ -376,7 +396,7 @@ on:goBack={closeViewer} on:showDetail={showDetailInfoHandler} on:download={() => downloadFile(asset)} - on:delete={() => (isShowDeleteConfirmation = true)} + on:delete={trashOrDelete} on:favorite={toggleFavorite} on:addToAlbum={() => openAlbumPicker(false)} on:addToSharedAlbum={() => openAlbumPicker(true)} diff --git a/web/src/lib/components/photos-page/actions/delete-assets.svelte b/web/src/lib/components/photos-page/actions/delete-assets.svelte index 1f9e11e737..9c0933516a 100644 --- a/web/src/lib/components/photos-page/actions/delete-assets.svelte +++ b/web/src/lib/components/photos-page/actions/delete-assets.svelte @@ -9,12 +9,16 @@ import { api } from '@api'; import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte'; import TimerSand from 'svelte-material-icons/TimerSand.svelte'; + import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import { OnAssetDelete, getAssetControlContext } from '../asset-select-control-bar.svelte'; import { createEventDispatcher } from 'svelte'; + import { featureFlags } from '$lib/stores/server-config.store'; export let onAssetDelete: OnAssetDelete; export let menuItem = false; + export let force = !$featureFlags.trash; + const { getAssets, clearSelect } = getAssetControlContext(); const dispatch = createEventDispatcher(); @@ -22,27 +26,29 @@ let isShowConfirmation = false; let loading = false; + const handleTrash = async () => { + if (force) { + isShowConfirmation = true; + return; + } + + await handleDelete(); + }; + const handleDelete = async () => { loading = true; try { - let count = 0; - - const { data: deletedAssets } = await api.assetApi.deleteAsset({ - deleteAssetDto: { - ids: Array.from(getAssets()).map((a) => a.id), - }, - }); - - for (const asset of deletedAssets) { - if (asset.status === 'SUCCESS') { - onAssetDelete(asset.id); - count++; - } + const ids = Array.from(getAssets()) + .filter((a) => !a.isExternal) + .map((a) => a.id); + await api.assetApi.deleteAssets({ assetBulkDeleteDto: { ids, force } }); + for (const id of ids) { + onAssetDelete(id); } notificationController.show({ - message: `Deleted ${count}`, + message: `${force ? 'Permanently deleted' : 'Trashed'} ${ids.length} assets`, type: NotificationType.Info, }); @@ -62,20 +68,16 @@ {#if menuItem} - (isShowConfirmation = true)} /> -{/if} - -{#if !menuItem} - {#if loading} - - {:else} - (isShowConfirmation = true)} /> - {/if} + +{:else if loading} + +{:else} + {/if} {#if isShowConfirmation} (isShowConfirmation = false)} @@ -83,7 +85,7 @@ >

- Are you sure you want to delete + Are you sure you want to permanently delete {#if getAssets().size > 1} these {getAssets().size} assets? This will also remove them from their album(s). {:else} diff --git a/web/src/lib/components/photos-page/actions/restore-assets.svelte b/web/src/lib/components/photos-page/actions/restore-assets.svelte new file mode 100644 index 0000000000..07c4cf5430 --- /dev/null +++ b/web/src/lib/components/photos-page/actions/restore-assets.svelte @@ -0,0 +1,43 @@ + + + diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index af1d0c7700..db71bd4ff3 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -17,6 +17,7 @@ import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte'; import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; import AssetDateGroup from './asset-date-group.svelte'; + import { featureFlags } from '$lib/stores/server-config.store'; import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; export let isSelectionMode = false; @@ -24,6 +25,8 @@ export let assetStore: AssetStore; export let assetInteractionStore: AssetInteractionStore; export let removeAction: AssetAction | null = null; + $: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash; + export let forceDelete = false; const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } = assetInteractionStore; @@ -383,6 +386,7 @@ handlePrevious()} on:next={() => handleNext()} on:close={() => handleClose()} diff --git a/web/src/lib/components/photos-page/asset-select-control-bar.svelte b/web/src/lib/components/photos-page/asset-select-control-bar.svelte index 4e6d35ef5c..3d9d94b086 100644 --- a/web/src/lib/components/photos-page/asset-select-control-bar.svelte +++ b/web/src/lib/components/photos-page/asset-select-control-bar.svelte @@ -2,6 +2,7 @@ import { createContext } from '$lib/utils/context'; export type OnAssetDelete = (assetId: string) => void; + export type OnRestore = (ids: string[]) => void; export type OnArchive = (ids: string[], isArchived: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void; diff --git a/web/src/lib/components/shared-components/empty-placeholder.svelte b/web/src/lib/components/shared-components/empty-placeholder.svelte index 836e0c1f35..8e99502d8f 100644 --- a/web/src/lib/components/shared-components/empty-placeholder.svelte +++ b/web/src/lib/components/shared-components/empty-placeholder.svelte @@ -4,6 +4,7 @@ export let actionHandler: undefined | (() => unknown) = undefined; export let text = ''; export let alt = ''; + export let src = empty1Url; let hoverClasses = 'hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 hover:cursor-pointer'; @@ -15,14 +16,14 @@ on:keydown={actionHandler} class="border dark:border-immich-dark-gray {hoverClasses} m-auto mt-10 flex w-[50%] flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray" > - +

{text}

{:else}
- +

{text}

{/if} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index d5818a079c..740513a704 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -10,6 +10,7 @@ import Magnify from 'svelte-material-icons/Magnify.svelte'; import Map from 'svelte-material-icons/Map.svelte'; import Account from 'svelte-material-icons/Account.svelte'; + import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte'; import HeartMultipleOutline from 'svelte-material-icons/HeartMultipleOutline.svelte'; import HeartMultiple from 'svelte-material-icons/HeartMultiple.svelte'; import { AppRoute } from '../../../constants'; @@ -37,6 +38,7 @@ const isFavoritesSelected = $page.route.id === '/(user)/favorites'; const isPhotosSelected = $page.route.id === '/(user)/photos'; const isSharingSelected = $page.route.id === '/(user)/sharing'; + const isTrashSelected = $page.route.id === '/(user)/trash'; @@ -139,6 +141,23 @@ {/await}
+ + {#if $featureFlags.trash} +
+ + + {#await getStats({ isTrashed: true })} + + {:then data} +
+

{data.videos.toLocaleString($locale)} Videos

+

{data.images.toLocaleString($locale)} Photos

+
+ {/await} +
+
+
+ {/if} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index cc5aaa0069..33d311ed08 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -3,6 +3,8 @@ export enum AssetAction { UNARCHIVE = 'unarchive', FAVORITE = 'favorite', UNFAVORITE = 'unfavorite', + TRASH = 'trash', + RESTORE = 'restore', } export enum AppRoute { @@ -24,6 +26,7 @@ export enum AppRoute { MAP = '/map', USER_SETTINGS = '/user-settings', MEMORY = '/memory', + TRASH = '/trash', AUTH_LOGIN = '/auth/login', AUTH_LOGOUT = '/auth/logout', diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index ff72b3f4b6..0cc9911e07 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -16,6 +16,7 @@ export const featureFlags = writable({ oauthAutoLaunch: false, passwordLogin: true, configFile: false, + trash: true, }); export type ServerConfig = ServerConfigDto & { loaded: boolean }; @@ -25,6 +26,7 @@ export const serverConfig = writable({ oauthButtonText: '', mapTileUrl: '', loginPageMessage: '', + trashDays: 30, }); export const loadConfig = async () => { diff --git a/web/src/routes/(user)/trash/+page.server.ts b/web/src/routes/(user)/trash/+page.server.ts new file mode 100644 index 0000000000..e9a726311a --- /dev/null +++ b/web/src/routes/(user)/trash/+page.server.ts @@ -0,0 +1,16 @@ +import { AppRoute } from '$lib/constants'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load = (async ({ locals: { user } }) => { + if (!user) { + throw redirect(302, AppRoute.AUTH_LOGIN); + } + + return { + user, + meta: { + title: 'Trash', + }, + }; +}) satisfies PageServerLoad; diff --git a/web/src/routes/(user)/trash/+page.svelte b/web/src/routes/(user)/trash/+page.svelte new file mode 100644 index 0000000000..461784aa70 --- /dev/null +++ b/web/src/routes/(user)/trash/+page.svelte @@ -0,0 +1,112 @@ + + +{#if $isMultiSelectState} + assetInteractionStore.clearMultiselect()}> + + assetStore.removeAsset(assetId)} /> + assetStore.removeAssets(ids)} /> + +{/if} + +{#if $featureFlags.loaded && $featureFlags.trash} + +
+ +
+ + Restore All +
+
+ (isShowEmptyConfirmation = true)}> +
+ + Empty Trash +
+
+
+ + + + +
+{/if} + +{#if isShowEmptyConfirmation} + (isShowEmptyConfirmation = false)} + > + +

Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.

+

You cannot undo this action!

+
+
+{/if} diff --git a/web/src/routes/(user)/trash/photos/[assetId]/+page.svelte b/web/src/routes/(user)/trash/photos/[assetId]/+page.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/src/routes/(user)/trash/photos/[assetId]/+page.ts b/web/src/routes/(user)/trash/photos/[assetId]/+page.ts new file mode 100644 index 0000000000..6a840178cf --- /dev/null +++ b/web/src/routes/(user)/trash/photos/[assetId]/+page.ts @@ -0,0 +1,13 @@ +import { AppRoute } from '$lib/constants'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; +export const prerender = false; + +export const load: PageLoad = async ({ parent }) => { + const { user } = await parent(); + if (!user) { + throw redirect(302, AppRoute.AUTH_LOGIN); + } + + throw redirect(302, AppRoute.TRASH); +}; diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 8e7aaa4fd2..9dd10c7947 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -19,6 +19,7 @@ import ContentCopy from 'svelte-material-icons/ContentCopy.svelte'; import Download from 'svelte-material-icons/Download.svelte'; import type { PageData } from './$types'; + import TrashSettings from '$lib/components/admin-page/settings/trash-settings/trash-settings.svelte'; export let data: PageData; @@ -75,6 +76,10 @@ + + + +