1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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 <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
shenlong 2023-10-06 07:01:14 +00:00 committed by GitHub
parent fc93762230
commit 4a8887f37b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
117 changed files with 2570 additions and 645 deletions

View File

@ -356,6 +356,25 @@ export interface AllJobStatusResponseDto {
*/
'videoConversion': JobStatusDto;
}
/**
*
* @export
* @interface AssetBulkDeleteDto
*/
export interface AssetBulkDeleteDto {
/**
*
* @type {boolean}
* @memberof AssetBulkDeleteDto
*/
'force'?: boolean;
/**
*
* @type {Array<string>}
* @memberof AssetBulkDeleteDto
*/
'ids': Array<string>;
}
/**
*
* @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<string>}
* @memberof DeleteAssetDto
*/
'ids': Array<string>;
}
/**
*
* @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<RequestArgs> => {
// verify required parameter 'deleteAssetDto' is not null or undefined
assertParamExists('deleteAsset', 'deleteAssetDto', deleteAssetDto)
deleteAssets: async (assetBulkDeleteDto: AssetBulkDeleteDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<RequestArgs> => {
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<RequestArgs> => {
getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
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<RequestArgs> => {
getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<RequestArgs> => {
getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<RequestArgs> => {
// 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<RequestArgs> => {
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<Array<DeleteAssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAsset(deleteAssetDto, options);
async deleteAssets(assetBulkDeleteDto: AssetBulkDeleteDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
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<void>> {
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<AssetStatsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, options);
async getAssetStats(isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetStatsResponseDto>> {
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<Array<AssetResponseDto>>> {
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<Array<AssetResponseDto>>> {
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<Array<TimeBucketResponseDto>>> {
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<Array<TimeBucketResponseDto>>> {
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<void>> {
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<void>> {
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<Array<DeleteAssetResponseDto>> {
return localVarFp.deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(axios, basePath));
deleteAssets(requestParameters: AssetApiDeleteAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
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<File> {
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<void> {
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<AssetStatsResponseDto> {
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<Array<AssetResponseDto>> {
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<Array<TimeBucketResponseDto>> {
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<AssetFileUploadResponseDto> {
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<void> {
return localVarFp.restoreAssets(requestParameters.bulkIdsDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
restoreTrash(options?: AxiosRequestConfig): AxiosPromise<void> {
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.

View File

@ -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"
}

View File

@ -169,4 +169,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
COCOAPODS: 1.12.1
COCOAPODS: 1.11.3

View File

@ -17,6 +17,7 @@ final archiveProvider = StreamProvider<RenderList>((ref) async* {
.ownerIdEqualToAnyChecksum(user.isarId)
.filter()
.isArchivedEqualTo(true)
.isTrashedEqualTo(false)
.sortByFileCreatedAt();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =

View File

@ -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<bool> 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));
},
);
}

View File

@ -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. "

View File

@ -17,6 +17,7 @@ final favoriteAssetsProvider = StreamProvider<RenderList>((ref) async* {
.ownerIdEqualToAnyChecksum(user.isarId)
.filter()
.isFavoriteEqualTo(true)
.isTrashedEqualTo(false)
.sortByFileCreatedAt();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =

View File

@ -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)

View File

@ -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(),
],
),

View File

@ -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;

View File

@ -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<bool> {
final Isar _db;
final Ref _ref;
final TrashService _trashService;
final _log = Logger('TrashNotifier');
TrashNotifier(
this._trashService,
this._db,
this._ref,
) : super(false);
Future<void> 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<bool> restoreAssets(Iterable<Asset> 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<void> 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<TrashNotifier, bool>((ref) {
return TrashNotifier(
ref.watch(trashServiceProvider),
ref.watch(dbProvider),
ref,
);
});
final trashedAssetsProvider = StreamProvider<RenderList>((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);
}
});

View File

@ -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<TrashService>((ref) {
return TrashService(
ref.watch(apiServiceProvider),
);
});
class TrashService {
final _log = Logger("TrashService");
final ApiService _apiService;
TrashService(this._apiService);
Future<bool> restoreAssets(Iterable<Asset> assetList) async {
try {
List<String> 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<void> emptyTrash() async {
try {
await _apiService.assetApi.emptyTrash();
} catch (error, stack) {
_log.severe("Cannot empty trash ${error.toString()}", error, stack);
}
}
Future<void> restoreTrash() async {
try {
await _apiService.assetApi.restoreTrash();
} catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
}
}
}

View File

@ -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(<Asset>{});
final processing = useState(false);
void selectionListener(
bool multiselect,
Set<Asset> 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<void> 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<void> handleRestoreAll() async {
processing.value = true;
await ref.read(trashProvider.notifier).restoreTrash();
processing.value = false;
selectionEnabledHook.value = false;
}
Future<void> 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: <Widget>[
if (!selectionEnabledHook.value)
PopupMenuButton<void Function()>(
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()),
],
),
),
);
}
}

View File

@ -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 {

View File

@ -312,6 +312,12 @@ class _$AppRouter extends RootStackRouter {
),
);
},
TrashRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const TrashPage(),
);
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
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<void> {
const TrashRoute()
: super(
TrashRoute.name,
path: '/trash-page',
);
static const String name = 'TrashRoute';
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View File

@ -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<int> 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,
}""";
}
}

View File

@ -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<bool> {
@ -92,23 +91,45 @@ class AssetNotifier extends StateNotifier<bool> {
await _syncService.syncNewAssetToDb(newAsset);
}
Future<void> deleteAssets(Iterable<Asset> deleteAssets) async {
Future<bool> deleteAssets(
Iterable<Asset> 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<Asset>? 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<List<String>> _deleteLocalAssets(
@ -127,15 +148,14 @@ class AssetNotifier extends StateNotifier<bool> {
return [];
}
Future<Iterable<String>> _deleteRemoteAssets(
Future<Iterable<Asset>> _deleteRemoteAssets(
Iterable<Asset> assetsToDelete,
bool? force,
) async {
final Iterable<Asset> remote = assetsToDelete.where((e) => e.isRemote);
final List<DeleteAssetResponseDto> 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<void> toggleFavorite(List<Asset> 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 =

View File

@ -26,12 +26,14 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
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: "",

View File

@ -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<WebsocketState> {
});
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<WebsocketState> {
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
}
}
_handleOnConfigUpdate(dynamic data) {
ref.read(serverInfoProvider.notifier).getServerFeatures();
ref.read(serverInfoProvider.notifier).getServerConfig();
}
}
final websocketProvider =

View File

@ -64,7 +64,9 @@ class AssetService {
Future<List<Asset>?> _getRemoteAssets(User user) async {
try {
final List<AssetResponseDto>? 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<List<DeleteAssetResponseDto>?> deleteAssets(
Iterable<Asset> deleteAssets,
) async {
Future<bool> deleteAssets(
Iterable<Asset> deleteAssets, {
bool? force = false,
}) async {
try {
final List<String> 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

View File

@ -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

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -24,6 +24,7 @@ void main() {
fileName: '',
isFavorite: false,
isArchived: false,
isTrashed: false,
),
);
}

View File

@ -34,6 +34,7 @@ void main() {
fileName: localId ?? remoteId ?? "",
isFavorite: false,
isArchived: false,
isTrashed: false,
);
}

View File

@ -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": {

View File

@ -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)) ||

View File

@ -6,10 +6,12 @@ export type AssetStats = Record<AssetType, number>;
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<AssetEntity[]>;
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
getByUserId(pagination: PaginationOptions, userId: string): Paginated<AssetEntity>;
getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
getById(id: string): Promise<AssetEntity | null>;
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated<AssetEntity>;
getRandom(userId: string, count: number): Promise<AssetEntity[]>;
@ -110,12 +114,13 @@ export interface IAssetRepository {
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void>;
save(asset: Pick<AssetEntity, 'id'> & Partial<AssetEntity>): Promise<AssetEntity>;
remove(asset: AssetEntity): Promise<void>;
softDeleteAll(ids: string[]): Promise<void>;
restoreAll(ids: string[]): Promise<void>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;
getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
remove(asset: AssetEntity): Promise<AssetEntity>;
getById(assetId: string): Promise<AssetEntity>;
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
}

View File

@ -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<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
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);

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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);

View File

@ -15,6 +15,11 @@ export class AssetStatsDto {
@Transform(toBoolean)
@Optional()
isFavorite?: boolean;
@IsBoolean()
@Transform(toBoolean)
@Optional()
isTrashed?: boolean;
}
export class AssetStatsResponseDto {

View File

@ -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;
}

View File

@ -28,6 +28,11 @@ export class TimeBucketDto {
@IsBoolean()
@Transform(toBoolean)
isFavorite?: boolean;
@Optional()
@IsBoolean()
@Transform(toBoolean)
isTrashed?: boolean;
}
export class TimeBucketAssetDto extends TimeBucketDto {

View File

@ -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,

View File

@ -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;
}

View File

@ -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<JobName, QueueName> = {
// 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,

View File

@ -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;
}

View File

@ -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 }

View File

@ -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 } }],

View File

@ -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 } });

View File

@ -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 },
},
],
]);

View File

@ -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);
}
}
}
}

View File

@ -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;

View File

@ -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();

View File

@ -66,6 +66,7 @@ export class ServerInfoService {
return {
loginPageMessage,
mapTileUrl: config.map.tileUrl,
trashDays: config.trash.days,
oauthButtonText: config.oauth.buttonText,
};
}

View File

@ -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';

View File

@ -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;
}

View File

@ -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 {

View File

@ -102,17 +102,19 @@ export const defaults = Object.freeze<SystemConfig>({
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<FeatureFlag, boolean>;
@ -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,

View File

@ -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<SystemConfig>({
@ -110,18 +112,24 @@ const updatedConfig = Object.freeze<SystemConfig>({
quality: 80,
colorspace: Colorspace.P3,
},
trash: {
enabled: true,
days: 10,
},
});
describe(SystemConfigService.name, () => {
let sut: SystemConfigService;
let configMock: jest.Mocked<ISystemConfigRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>;
let jobMock: jest.Mocked<IJobRepository>;
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);

View File

@ -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<SystemConfigDto> {
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);
}

View File

@ -23,7 +23,6 @@ export interface AssetOwnerCheck extends AssetCheck {
export interface IAssetRepository {
get(id: string): Promise<AssetEntity | null>;
create(asset: AssetCreate): Promise<AssetEntity>;
remove(asset: AssetEntity): Promise<void>;
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
getById(assetId: string): Promise<AssetEntity>;
@ -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<void> {
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,
});
}

View File

@ -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<DeleteAssetResponseDto[]> {
return this.assetService.deleteAll(authUser, dto);
}
/**
* Check duplicated asset before uploading - for Web upload used
*/

View File

@ -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,

View File

@ -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');

View File

@ -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<DeleteAssetResponseDto[]> {
const deleteQueue: Array<string | null> = [];
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<string[]> {
const possibleSearchTerm = new Set<string>();

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -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<void> {
return this.service.deleteAll(authUser, dto);
}
@Post('restore')
@HttpCode(HttpStatus.NO_CONTENT)
restoreAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.restoreAll(authUser, dto);
}
@Post('trash/empty')
@HttpCode(HttpStatus.NO_CONTENT)
emptyTrash(@AuthUser() authUser: AuthUserDto): Promise<void> {
return this.service.handleTrashAction(authUser, TrashAction.EMPTY_ALL);
}
@Post('trash/restore')
@HttpCode(HttpStatus.NO_CONTENT)
restoreTrash(@AuthUser() authUser: AuthUserDto): Promise<void> {
return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL);
}
@Put(':id')
updateAsset(
@AuthUser() authUser: AuthUserDto,

View File

@ -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;

View File

@ -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;
};
}

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddAssetDeletedAtColumn1694204416744 implements MigrationInterface {
name = 'AddAssetDeletedAtColumn1694204416744'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "deletedAt" TIMESTAMP WITH TIME ZONE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "deletedAt"`);
}
}

View File

@ -86,6 +86,7 @@ export class AccessRepository implements IAccessRepository {
id: assetId,
ownerId: userId,
},
withDeleted: true,
});
},

View File

@ -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<AssetEntity> {
getByUserId(pagination: PaginationOptions, userId: string, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
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<AssetEntity> {
return this.repository.findOneOrFail({
where: {
id: assetId,
},
relations: {
exifInfo: true,
tags: true,
sharedLinks: true,
smartInfo: true,
faces: {
person: true,
},
},
});
}
remove(asset: AssetEntity): Promise<AssetEntity> {
return this.repository.remove(asset);
}
getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
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<AssetEntity | null> {
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<AssetEntity>): Promise<void> {
await this.repository.update({ id: In(ids) }, options);
}
async softDeleteAll(ids: string[]): Promise<void> {
await this.repository.softDelete({ id: In(ids), isExternal: false });
}
async restoreAll(ids: string[]): Promise<void> {
await this.repository.restore({ id: In(ids) });
}
async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
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<void> {
await this.repository.remove(asset);
}
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null> {
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')

View File

@ -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);
}
}

View File

@ -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')

View File

@ -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(),

View File

@ -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,
});
});
});

View File

@ -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<AssetEntity>({
id: 'asset-id',
@ -112,6 +114,7 @@ export const assetStub = {
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
deletedAt: null,
}),
image: Object.freeze<AssetEntity>({
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<AssetEntity>({
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<AssetEntity>({
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<AssetEntity>({
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,
}),
};

View File

@ -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,
},
],
},

View File

@ -9,6 +9,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
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<IAssetRepository> => {
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(),
};
};

View File

@ -3,5 +3,6 @@ import { ICommunicationRepository } from '@app/domain';
export const newCommunicationRepositoryMock = (): jest.Mocked<ICommunicationRepository> => {
return {
send: jest.fn(),
broadcast: jest.fn(),
};
};

View File

@ -356,6 +356,25 @@ export interface AllJobStatusResponseDto {
*/
'videoConversion': JobStatusDto;
}
/**
*
* @export
* @interface AssetBulkDeleteDto
*/
export interface AssetBulkDeleteDto {
/**
*
* @type {boolean}
* @memberof AssetBulkDeleteDto
*/
'force'?: boolean;
/**
*
* @type {Array<string>}
* @memberof AssetBulkDeleteDto
*/
'ids': Array<string>;
}
/**
*
* @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<string>}
* @memberof DeleteAssetDto
*/
'ids': Array<string>;
}
/**
*
* @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<RequestArgs> => {
// verify required parameter 'deleteAssetDto' is not null or undefined
assertParamExists('deleteAsset', 'deleteAssetDto', deleteAssetDto)
deleteAssets: async (assetBulkDeleteDto: AssetBulkDeleteDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<RequestArgs> => {
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<RequestArgs> => {
getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
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<RequestArgs> => {
getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<RequestArgs> => {
getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<RequestArgs> => {
// 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<RequestArgs> => {
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<Array<DeleteAssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAsset(deleteAssetDto, options);
async deleteAssets(assetBulkDeleteDto: AssetBulkDeleteDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
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<void>> {
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<AssetStatsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, options);
async getAssetStats(isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetStatsResponseDto>> {
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<Array<AssetResponseDto>>> {
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<Array<AssetResponseDto>>> {
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<Array<TimeBucketResponseDto>>> {
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<Array<TimeBucketResponseDto>>> {
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<void>> {
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<void>> {
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<Array<DeleteAssetResponseDto>> {
return localVarFp.deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(axios, basePath));
deleteAssets(requestParameters: AssetApiDeleteAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
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<File> {
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<void> {
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<AssetStatsResponseDto> {
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<Array<AssetResponseDto>> {
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<Array<TimeBucketResponseDto>> {
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<AssetFileUploadResponseDto> {
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<void> {
return localVarFp.restoreAssets(requestParameters.bulkIdsDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
restoreTrash(options?: AxiosRequestConfig): AxiosPromise<void> {
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.

Some files were not shown because too many files have changed in this diff Show More