From 2649e489903a44a9d15e9eaf44a662f46b59b3c8 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Thu, 16 May 2024 16:14:36 -0500 Subject: [PATCH 01/34] feat(web): deduplication UI --- .../admin-page/jobs/jobs-panel.svelte | 7 + .../machine-learning-settings.svelte | 34 +++++ .../side-bar/side-bar.svelte | 10 ++ .../utilities-page/utilities-page-link.svelte | 63 +++++++++ web/src/lib/constants.ts | 3 + web/src/routes/(user)/utilities/+page.svelte | 132 ++++++++++++++++++ web/src/routes/(user)/utilities/+page.ts | 15 ++ .../[[assetId=id]]/+page.svelte | 63 +++++++++ .../[[photos=photos]]/[[assetId=id]]/+page.ts | 15 ++ 9 files changed, 342 insertions(+) create mode 100644 web/src/lib/components/utilities-page/utilities-page-link.svelte create mode 100644 web/src/routes/(user)/utilities/+page.svelte create mode 100644 web/src/routes/(user)/utilities/+page.ts create mode 100644 web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte create mode 100644 web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 0d01e2c3e49ca..e2ac36f29f53b 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -8,6 +8,7 @@ import { handleError } from '$lib/utils/handle-error'; import { JobCommand, JobName, sendJobCommand, type AllJobStatusResponseDto, type JobCommandDto } from '@immich/sdk'; import { + mdiContentDuplicate, mdiFaceRecognition, mdiFileJpgBox, mdiFileXmlBox, @@ -88,6 +89,12 @@ subtitle: 'Run machine learning on assets to support smart search', disabled: !$featureFlags.smartSearch, }, + [JobName.DuplicateDetection]: { + icon: mdiContentDuplicate, + title: getJobName(JobName.DuplicateDetection), + subtitle: 'Run machine learning on assets to detect similar images', + disabled: !$featureFlags.duplicateDetection, + }, [JobName.FaceDetection]: { icon: mdiFaceRecognition, title: getJobName(JobName.FaceDetection), diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index f032fa8c80901..b2f35f9f15d31 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -77,6 +77,40 @@ + +
+ + +
+ + +
+
+ @@ -136,6 +139,13 @@ + + + import { fade } from 'svelte/transition'; + import Icon from '$lib/components/elements/icon.svelte'; + import { mdiInformationOutline } from '@mdi/js'; + import { resolveRoute } from '$app/paths'; + import { page } from '$app/stores'; + export let title: string; + export let routeId: string; + export let icon: string; + export let flippedLogo = false; + export let isSelected = false; + export let preloadData = true; + let showMoreInformation = false; + $: routePath = resolveRoute(routeId, {}); + $: isSelected = ($page.route.id?.match(/^\/(admin|\(user\))\/[^/]*/) || [])[0] === routeId; + + + +
+ + {title} +
+ + +
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index ec393a57b90d2..a9b7b8929dabb 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -39,6 +39,9 @@ export enum AppRoute { AUTH_REGISTER = '/auth/register', AUTH_CHANGE_PASSWORD = '/auth/change-password', AUTH_ONBOARDING = '/auth/onboarding', + + UTILITIES = '/utilities', + DUPLICATES = '/utilities/duplicates', } export enum ProjectionType { diff --git a/web/src/routes/(user)/utilities/+page.svelte b/web/src/routes/(user)/utilities/+page.svelte new file mode 100644 index 0000000000000..72d8843c2762e --- /dev/null +++ b/web/src/routes/(user)/utilities/+page.svelte @@ -0,0 +1,132 @@ + + + +
+
+ +

Duplicates

+
+ +
+
+ +


+ + + +
+
+ + + +
+
+ +
+
+
+
+ title and icon +
+ +

blah subtitle

+ +
+

description

+
+ +
+
+

Active

+

1

+
+ +
+

1

+

Waiting

+
+
+
+
+
+
diff --git a/web/src/routes/(user)/utilities/+page.ts b/web/src/routes/(user)/utilities/+page.ts new file mode 100644 index 0000000000000..1a62d6ec3f22b --- /dev/null +++ b/web/src/routes/(user)/utilities/+page.ts @@ -0,0 +1,15 @@ +import { authenticate } from '$lib/utils/auth'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params }) => { + await authenticate(); + const asset = await getAssetInfoFromParam(params); + + return { + asset, + meta: { + title: 'Utilities', + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000000..dcba437ce2a0d --- /dev/null +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,63 @@ + + +{#if $isMultiSelectState} + assetInteractionStore.clearMultiselect()}> + + + + + + + assetStore.triggerUpdate()} /> + + + assetStore.removeAssets(assetIds)} /> + + +{/if} + + +
+ {#if isEmpty} + + {/if} + +
+
diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000000..45758a725ab25 --- /dev/null +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,15 @@ +import { authenticate } from '$lib/utils/auth'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params }) => { + await authenticate(); + const asset = await getAssetInfoFromParam(params); + + return { + asset, + meta: { + title: 'Duplicates', + }, + }; +}) satisfies PageLoad; From a36e7fb0d43081db22e61229466ccc4fa3ae6be9 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Thu, 16 May 2024 16:22:19 -0500 Subject: [PATCH 02/34] fix: missing authenticated decorator --- mobile/openapi/doc/AssetApi.md | 16 ++++- open-api/immich-openapi-specs.json | 11 ++++ server/src/controllers/asset.controller.ts | 1 + web/src/routes/(user)/utilities/+page.svelte | 61 +------------------ .../[[assetId=id]]/+page.svelte | 53 ++-------------- 5 files changed, 32 insertions(+), 110 deletions(-) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index da070ccfc4f41..3411f364b889d 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -333,6 +333,20 @@ Name | Type | Description | Notes ### Example ```dart import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); final api_instance = AssetApi(); @@ -353,7 +367,7 @@ This endpoint does not need any parameter. ### Authorization -No authorization required +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) ### HTTP request headers diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 425bf81714eef..a4b7aa19c7181 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1213,6 +1213,17 @@ "description": "" } }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], "tags": [ "Asset" ] diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 7e51f17b59040..016d1c27e8ce9 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -58,6 +58,7 @@ export class AssetController { } @Get('duplicates') + @Authenticated() getAssetDuplicates(@Auth() auth: AuthDto): Promise { return this.service.getDuplicates(auth); } diff --git a/web/src/routes/(user)/utilities/+page.svelte b/web/src/routes/(user)/utilities/+page.svelte index 72d8843c2762e..7f162194e46a5 100644 --- a/web/src/routes/(user)/utilities/+page.svelte +++ b/web/src/routes/(user)/utilities/+page.svelte @@ -6,6 +6,7 @@ import Icon from '$lib/components/elements/icon.svelte'; import { AppRoute } from '$lib/constants'; import UtilitiesPageLink from '$lib/components/utilities-page/utilities-page-link.svelte'; + export let data: PageData; @@ -69,64 +70,4 @@ - -
-
- - - -
-
- -
-
-
-
- title and icon -
- -

blah subtitle

- -
-

description

-
- -
-
-

Active

-

1

-
- -
-

1

-

Waiting

-
-
-
-
-
diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte index dcba437ce2a0d..f9bbffd80a17c 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,63 +1,18 @@ -{#if $isMultiSelectState} - assetInteractionStore.clearMultiselect()}> - - - - - - - assetStore.triggerUpdate()} /> - - - assetStore.removeAssets(assetIds)} /> - - -{/if} - - -
- {#if isEmpty} - - {/if} - -
-
+OK From 3647ebb372e9fb89cbe24c182dc8cbf2396b1b3a Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Fri, 17 May 2024 08:44:46 -0500 Subject: [PATCH 03/34] merge main --- mobile/openapi/README.md | 1 - mobile/openapi/doc/AssetApi.md | 52 --------------------- mobile/openapi/lib/api/asset_api.dart | 44 ----------------- mobile/openapi/test/asset_api_test.dart | 5 -- open-api/immich-openapi-specs.json | 35 -------------- open-api/typescript-sdk/src/fetch-client.ts | 8 ---- 6 files changed, 145 deletions(-) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4afeb179a4afd..b5a49aa26c262 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -98,7 +98,6 @@ Class | Method | HTTP request | Description *AssetApi* | [**deleteAssets**](doc//AssetApi.md#deleteassets) | **DELETE** /asset | *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | *AssetApi* | [**getAllUserAssetsByDeviceId**](doc//AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} | -*AssetApi* | [**getAssetDuplicates**](doc//AssetApi.md#getassetduplicates) | **GET** /asset/duplicates | *AssetApi* | [**getAssetInfo**](doc//AssetApi.md#getassetinfo) | **GET** /asset/{id} | *AssetApi* | [**getAssetStatistics**](doc//AssetApi.md#getassetstatistics) | **GET** /asset/statistics | *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 3411f364b889d..a1491c79a2d81 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -14,7 +14,6 @@ Method | HTTP request | Description [**deleteAssets**](AssetApi.md#deleteassets) | **DELETE** /asset | [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | [**getAllUserAssetsByDeviceId**](AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} | -[**getAssetDuplicates**](AssetApi.md#getassetduplicates) | **GET** /asset/duplicates | [**getAssetInfo**](AssetApi.md#getassetinfo) | **GET** /asset/{id} | [**getAssetStatistics**](AssetApi.md#getassetstatistics) | **GET** /asset/statistics | [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | @@ -325,57 +324,6 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) -# **getAssetDuplicates** -> List getAssetDuplicates() - - - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = AssetApi(); - -try { - final result = api_instance.getAssetDuplicates(); - print(result); -} catch (e) { - print('Exception when calling AssetApi->getAssetDuplicates: $e\n'); -} -``` - -### Parameters -This endpoint does not need any parameter. - -### Return type - -[**List**](AssetResponseDto.md) - -### Authorization - -[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - # **getAssetInfo** > AssetResponseDto getAssetInfo(id, key) diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 5c81b89c58a3f..dba33fc181ba9 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -326,50 +326,6 @@ class AssetApi { return null; } - /// Performs an HTTP 'GET /asset/duplicates' operation and returns the [Response]. - Future getAssetDuplicatesWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/asset/duplicates'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - Future?> getAssetDuplicates() async { - final response = await getAssetDuplicatesWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() - .toList(growable: false); - - } - return null; - } - /// Performs an HTTP 'GET /asset/{id}' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 4ab806f35b573..de84e53546274 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -50,11 +50,6 @@ void main() { // TODO }); - //Future> getAssetDuplicates() async - test('test getAssetDuplicates', () async { - // TODO - }); - //Future getAssetInfo(String id, { String key }) async test('test getAssetInfo', () async { // TODO diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index a4b7aa19c7181..f59bc7a0ad92a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1194,41 +1194,6 @@ ] } }, - "/asset/duplicates": { - "get": { - "operationId": "getAssetDuplicates", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Asset" - ] - } - }, "/asset/exist": { "post": { "description": "Checks if multiple assets exist on the server and returns all existing - used by background backup", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 020188c8a84ae..996f69de83f5a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1407,14 +1407,6 @@ export function getAllUserAssetsByDeviceId({ deviceId }: { ...opts })); } -export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AssetResponseDto[]; - }>("/asset/duplicates", { - ...opts - })); -} /** * Checks if multiple assets exist on the server and returns all existing - used by background backup */ From 67131157cfdd0881f195bb7106bf56d36919a044 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Fri, 17 May 2024 09:40:47 -0500 Subject: [PATCH 04/34] utilities menu --- .../utilities-page/utilities-menu.svelte | 15 +++++ .../utilities-page/utilities-page-link.svelte | 63 ------------------ web/src/routes/(user)/utilities/+page.svelte | 66 +------------------ 3 files changed, 18 insertions(+), 126 deletions(-) create mode 100644 web/src/lib/components/utilities-page/utilities-menu.svelte delete mode 100644 web/src/lib/components/utilities-page/utilities-page-link.svelte diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte new file mode 100644 index 0000000000000..fb1726fa827b9 --- /dev/null +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -0,0 +1,15 @@ + + +
+

ORGANIZE YOUR LIBRARY

+ + +
diff --git a/web/src/lib/components/utilities-page/utilities-page-link.svelte b/web/src/lib/components/utilities-page/utilities-page-link.svelte deleted file mode 100644 index 07048399fadae..0000000000000 --- a/web/src/lib/components/utilities-page/utilities-page-link.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - - -
- - {title} -
- - -
diff --git a/web/src/routes/(user)/utilities/+page.svelte b/web/src/routes/(user)/utilities/+page.svelte index 7f162194e46a5..cef02c2b815d8 100644 --- a/web/src/routes/(user)/utilities/+page.svelte +++ b/web/src/routes/(user)/utilities/+page.svelte @@ -1,73 +1,13 @@ -
-
- -

Duplicates

-
- -
-
- -


- -
- +
+
From 2bd8c94d52ae485f7e7e72c3e56bacbdd3a2620c Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Fri, 17 May 2024 11:31:42 -0500 Subject: [PATCH 05/34] router --- web/src/hooks.client.ts | 1 + .../utilities-page/utilities-menu.svelte | 21 +++++++++++-------- .../[[assetId=id]]/+page.svelte | 6 ++---- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 1 + 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/web/src/hooks.client.ts b/web/src/hooks.client.ts index e78f21a5f2801..7bc2aec00b208 100644 --- a/web/src/hooks.client.ts +++ b/web/src/hooks.client.ts @@ -31,6 +31,7 @@ const parseError = (error: unknown, status: number, message: string) => { }; export const handleError: HandleClientError = ({ error, status, message }) => { + console.log(error); const result = parseError(error, status, message); console.error(`[hooks.client.ts]:handleError ${result.message}`); return result; diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte index fb1726fa827b9..66534c1547d82 100644 --- a/web/src/lib/components/utilities-page/utilities-menu.svelte +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -1,15 +1,18 @@ - + diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte index f9bbffd80a17c..7218ddc840cde 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,6 +1,6 @@ OK diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts index 45758a725ab25..76edca76ff336 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,6 +3,7 @@ import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; export const load = (async ({ params }) => { + console.log(params); await authenticate(); const asset = await getAssetInfoFromParam(params); From aa6228201c970b5cc150a7e457ecd347fb75a0d2 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Fri, 17 May 2024 15:05:24 -0500 Subject: [PATCH 06/34] naming convention --- web/src/lib/components/utilities-page/utilities-menu.svelte | 4 ++-- web/src/routes/(user)/utilities/+page.svelte | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte index 66534c1547d82..81759fd8306a4 100644 --- a/web/src/lib/components/utilities-page/utilities-menu.svelte +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -5,14 +5,14 @@ -
+

ORGANIZE YOUR LIBRARY

diff --git a/web/src/routes/(user)/utilities/+page.svelte b/web/src/routes/(user)/utilities/+page.svelte index cef02c2b815d8..bf18b99436aa9 100644 --- a/web/src/routes/(user)/utilities/+page.svelte +++ b/web/src/routes/(user)/utilities/+page.svelte @@ -8,6 +8,8 @@
- +
+ +
From 534142270fc92ed0954f3807f2761ab765d06edd Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Fri, 17 May 2024 16:16:47 -0500 Subject: [PATCH 07/34] get assets --- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 7218ddc840cde..cf50cd09c3f6c 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,16 +1,16 @@ OK From 74c49876a39532b63c855415ce61428d14dd195b Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 18 May 2024 13:41:22 -0500 Subject: [PATCH 08/34] openapi --- open-api/immich-openapi-specs.json | 24 ++++++++++++++++++- .../duplicates-compare-control.svelte | 1 + .../[[assetId=id]]/+page.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 4 +++- 4 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7fbf5f8302d9a..8fc5378edf8e1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2231,7 +2231,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/AssetResponseDto" + "$ref": "#/components/schemas/DuplicateResponseDto" }, "type": "array" } @@ -7318,6 +7318,10 @@ "deviceId": { "type": "string" }, + "duplicateId": { + "nullable": true, + "type": "string" + }, "duration": { "type": "string" }, @@ -7930,6 +7934,24 @@ ], "type": "object" }, + "DuplicateResponseDto": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + }, + "duplicateId": { + "type": "string" + } + }, + "required": [ + "assets", + "duplicateId" + ], + "type": "object" + }, "EntityType": { "enum": [ "ASSET", diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte new file mode 100644 index 0000000000000..5bf03f4a781cb --- /dev/null +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -0,0 +1 @@ + diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte index cf50cd09c3f6c..ff114fd4e1790 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -13,4 +13,4 @@ }); -OK + diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts index 76edca76ff336..67c33b85fd8be 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -1,14 +1,16 @@ import { authenticate } from '$lib/utils/auth'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { getAssetDuplicates } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async ({ params }) => { - console.log(params); await authenticate(); const asset = await getAssetInfoFromParam(params); + const duplicates = await getAssetDuplicates(); return { asset, + duplicates, meta: { title: 'Duplicates', }, From 20f8c660316e37a388ced909839dd8ddfb77b8af Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 18 May 2024 14:17:09 -0500 Subject: [PATCH 09/34] update --- .../duplicates/duplicates-compare-control.svelte | 10 +++++++++- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 15 ++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 5bf03f4a781cb..247c7c6ffcc36 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -1 +1,9 @@ - + + +
+ {duplicate.duplicateId} +
diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte index ff114fd4e1790..c833c8dc32f44 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,16 +1,17 @@ - + +
+ {#each data.duplicates as duplicate (duplicate.duplicateId)} + + {/each} +
+
From d168b135d55b55f6da85e95a07c9a972998c9a8a Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 18 May 2024 15:30:06 -0500 Subject: [PATCH 10/34] building out controller --- .../duplicates-compare-control.svelte | 18 +++++++++++++++--- .../[[assetId=id]]/+page.svelte | 4 ++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 247c7c6ffcc36..187d8c1327cd4 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -1,9 +1,21 @@ -
- {duplicate.duplicateId} +
+
+ {#each duplicate.assets as asset} +
+ {asset.id} +
+ + +
+
+ {/each} +
diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte index c833c8dc32f44..e15949760feb6 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -8,8 +8,8 @@ let assets: AssetResponseDto[] = []; - -
+ +
{#each data.duplicates as duplicate (duplicate.duplicateId)} {/each} From 86425ef51ae9fe843cc625e64dabd4b6ccb6bb4b Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sun, 19 May 2024 08:09:38 -0500 Subject: [PATCH 11/34] action button --- .../duplicates-compare-control.svelte | 24 ++++++++++++++----- .../[[assetId=id]]/+page.svelte | 2 +- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 187d8c1327cd4..93583bd037c34 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -2,18 +2,30 @@ import Button from '$lib/components/elements/buttons/button.svelte'; import { getAssetThumbnailUrl } from '$lib/utils'; import { ThumbnailFormat, type DuplicateResponseDto } from '@immich/sdk'; + import Icon from '$lib/components/elements/icon.svelte'; + import { mdiCheckCircleOutline, mdiCloseCircleOutline } from '@mdi/js'; export let duplicate: DuplicateResponseDto; -
-
+
+
{#each duplicate.assets as asset}
- {asset.id} -
- - + {asset.id} +
+ +
{/each} diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte index e15949760feb6..300a5eebd3732 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -9,7 +9,7 @@ -
+
{#each data.duplicates as duplicate (duplicate.duplicateId)} {/each} From 7d7033fbcdb05120afee502d3e1e6c4d4b993f6f Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sun, 19 May 2024 10:54:51 -0500 Subject: [PATCH 12/34] UI work --- .../duplicates-compare-control.svelte | 40 +++++++++++++++---- .../[[assetId=id]]/+page.svelte | 4 +- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 93583bd037c34..78eb988305d03 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -1,5 +1,4 @@ -
-
- {#each duplicate.assets as asset} -
+
+
+ {#each duplicate.assets as asset, index (index)} +
+ {asset.id} + + + + + + + + + +
{asset.exifInfo?.fileSizeInByte} bytes
{asset.originalFileName}
+ +
{/each}
+ + +
+ + +
diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 300a5eebd3732..f5ea13085ed92 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,14 +1,12 @@ - +
{#each data.duplicates as duplicate (duplicate.duplicateId)} From 659478de7041d274deb4bc84b69ba01bbd0c0dbe Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Mon, 20 May 2024 13:26:14 -0500 Subject: [PATCH 13/34] color --- .../duplicates-compare-control.svelte | 68 +++++++++---------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 78eb988305d03..d1c4765bccf08 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -1,58 +1,54 @@ -
+
{#each duplicate.assets as asset, index (index)} -
- - {asset.id} - + {@const isCandidate = selectedAsset.id === asset.id} +
+ - - - + +
{asset.exifInfo?.fileSizeInByte} bytes
+ + - - + +
{asset.exifInfo?.fileSizeInByte} bytes
{asset.originalFileName}
{asset.originalFileName}
- - -
- - -
{/each}
-
-
From f4e2e26c32db26496d9254cdec63f74887c96b6f Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Mon, 20 May 2024 15:26:39 -0500 Subject: [PATCH 14/34] chip --- .../components/elements/buttons/button.svelte | 2 +- .../duplicates-compare-control.svelte | 51 +++++++++++++------ 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/web/src/lib/components/elements/buttons/button.svelte b/web/src/lib/components/elements/buttons/button.svelte index 9536f20f2353a..76f52d77351be 100644 --- a/web/src/lib/components/elements/buttons/button.svelte +++ b/web/src/lib/components/elements/buttons/button.svelte @@ -43,7 +43,7 @@ 'text-immich-primary dark:text-immich-dark-primary enabled:dark:hover:bg-immich-dark-primary/10 enabled:hover:bg-immich-primary/10', 'light-red': 'bg-[#F9DEDC] text-[#410E0B] enabled:hover:bg-red-50', red: 'bg-red-500 text-white enabled:hover:bg-red-400', - green: 'bg-green-500 text-gray-800 enabled:hover:bg-green-400/90', + green: 'bg-green-400 text-gray-800 enabled:hover:bg-green-400/90', gray: 'bg-gray-500 dark:bg-gray-200 enabled:hover:bg-gray-500/75 enabled:dark:hover:bg-gray-200/80 text-white dark:text-immich-dark-gray', 'transparent-gray': 'dark:text-immich-dark-fg enabled:hover:bg-immich-primary/5 enabled:hover:text-gray-700 enabled:hover:dark:text-immich-dark-fg enabled:dark:hover:bg-immich-dark-primary/25', diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index d1c4765bccf08..43f15fee061eb 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -1,6 +1,11 @@ -
+
{#each duplicate.assets as asset, index (index)} {@const isCandidate = selectedAsset.id === asset.id} -
+
- - - - + + + +
{asset.exifInfo?.fileSizeInByte} bytes
{asset.originalFileName}
{asset.exifInfo?.exifImageWidth}x{asset.exifInfo?.exifImageHeight} - {asByteUnitString( + Number(asset.exifInfo?.fileSizeInByte), + $locale, + 4, + )}
{/each}
-
- - + + +
From 89e3282e78c72a3d57925a1f42d3983efb4456e0 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Mon, 20 May 2024 15:41:52 -0500 Subject: [PATCH 15/34] functionalities --- .../duplicates/duplicates-compare-control.svelte | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 43f15fee061eb..ffcbcbbf9d77d 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -8,6 +8,9 @@ import { mdiDelete, mdiImageCheckOutline, mdiImagePlusOutline } from '@mdi/js'; export let duplicate: DuplicateResponseDto; + export let onResolve: (keepIds: string[], trashIds: string[]) => void; + export let onTrashAll: (ids: string[]) => void; + export let onKeepAll: (ids: string[]) => void; let selectedAsset = duplicate.assets.sort((a, b) => b.exifInfo!.fileSizeInByte! - a.exifInfo!.fileSizeInByte!)[0]; From 7de0ab7a09dbea14f9f9929c8fe4aefed0e3a8d0 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Mon, 20 May 2024 17:21:03 -0500 Subject: [PATCH 16/34] finish ui --- .../duplicates-compare-control.svelte | 82 ++++++++++++++----- 1 file changed, 61 insertions(+), 21 deletions(-) diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index ffcbcbbf9d77d..ffc0e0152f6fc 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -5,24 +5,54 @@ import { getAssetThumbnailUrl } from '$lib/utils'; import { asByteUnitString } from '$lib/utils/byte-units'; import { ThumbnailFormat, type AssetResponseDto, type DuplicateResponseDto } from '@immich/sdk'; - import { mdiDelete, mdiImageCheckOutline, mdiImagePlusOutline } from '@mdi/js'; + import { + mdiCheck, + mdiCheckOutline, + mdiImageCheckOutline, + mdiImageMultipleOutline, + mdiTrashCan, + mdiTrashCanOutline, + } from '@mdi/js'; + import { onMount } from 'svelte'; export let duplicate: DuplicateResponseDto; export let onResolve: (keepIds: string[], trashIds: string[]) => void; - export let onTrashAll: (ids: string[]) => void; - export let onKeepAll: (ids: string[]) => void; - let selectedAsset = duplicate.assets.sort((a, b) => b.exifInfo!.fileSizeInByte! - a.exifInfo!.fileSizeInByte!)[0]; + let selectedAsset = new Set(); + $: trashCount = duplicate.assets.length - selectedAsset.size; + + onMount(() => { + const suggestedAsset = duplicate.assets.sort( + (a, b) => b.exifInfo!.fileSizeInByte! - a.exifInfo!.fileSizeInByte!, + )[0]; + + selectedAsset.add(suggestedAsset.id); + selectedAsset = new Set(selectedAsset); + }); const onSelectAsset = (asset: AssetResponseDto) => { - selectedAsset = asset; + if (selectedAsset.has(asset.id)) { + selectedAsset.delete(asset.id); + } else { + selectedAsset.add(asset.id); + } + + selectedAsset = new Set(selectedAsset); + }; + + const handleOnResolve = () => { + const keepIds = Array.from(selectedAsset); + const trashIds = duplicate.assets.map((asset) => asset.id).filter((id) => !keepIds.includes(id)); + + onResolve(keepIds, trashIds); }; -
-
+
+
{#each duplicate.assets as asset, index (index)} - {@const isCandidate = selectedAsset.id === asset.id} + {@const isSelected = selectedAsset.has(asset.id)} +
+ + + + + + + +
{asset.originalFileName}
{asset.exifInfo?.exifImageWidth}x{asset.exifInfo?.exifImageHeight} - {asByteUnitString( @@ -68,12 +98,22 @@ -
- - - +
+
+

DUPLICATE ID {duplicate.duplicateId}

+

TOTAL {duplicate.assets.length}

+
+ + {#if trashCount == 0} + + {:else} + + {/if}
From 468a80a2c20bac7089505b51db911bf0e5f82f1e Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Mon, 20 May 2024 21:48:55 -0500 Subject: [PATCH 17/34] implemented functionality --- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | 2 + mobile/openapi/doc/DuplicateApi.md | 55 +++++++++ mobile/openapi/doc/ResolveDuplicatesDto.md | 16 +++ mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/duplicate_api.dart | 39 +++++++ mobile/openapi/lib/api_client.dart | 2 + .../lib/model/resolve_duplicates_dto.dart | 108 ++++++++++++++++++ mobile/openapi/test/duplicate_api_test.dart | 5 + .../test/resolve_duplicates_dto_test.dart | 32 ++++++ open-api/immich-openapi-specs.json | 53 +++++++++ open-api/typescript-sdk/src/fetch-client.ts | 13 +++ .../src/controllers/duplicate.controller.ts | 10 +- server/src/dtos/duplicate.dto.ts | 9 ++ server/src/services/duplicate.service.spec.ts | 6 +- server/src/services/duplicate.service.ts | 21 +++- .../duplicates-compare-control.svelte | 16 +-- .../[[assetId=id]]/+page.svelte | 13 ++- 18 files changed, 387 insertions(+), 17 deletions(-) create mode 100644 mobile/openapi/doc/ResolveDuplicatesDto.md create mode 100644 mobile/openapi/lib/model/resolve_duplicates_dto.dart create mode 100644 mobile/openapi/test/resolve_duplicates_dto_test.dart diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 69172bd9775d5..bbbd84ebb18aa 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -131,6 +131,7 @@ doc/QueueStatusDto.md doc/ReactionLevel.md doc/ReactionType.md doc/RecognitionConfig.md +doc/ResolveDuplicatesDto.md doc/ReverseGeocodingStateResponseDto.md doc/ScanLibraryDto.md doc/SearchAlbumResponseDto.md @@ -366,6 +367,7 @@ lib/model/queue_status_dto.dart lib/model/reaction_level.dart lib/model/reaction_type.dart lib/model/recognition_config.dart +lib/model/resolve_duplicates_dto.dart lib/model/reverse_geocoding_state_response_dto.dart lib/model/scan_library_dto.dart lib/model/search_album_response_dto.dart @@ -570,6 +572,7 @@ test/queue_status_dto_test.dart test/reaction_level_test.dart test/reaction_type_test.dart test/recognition_config_test.dart +test/resolve_duplicates_dto_test.dart test/reverse_geocoding_state_response_dto_test.dart test/scan_library_dto_test.dart test/search_album_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 048d5b00a0d5d..6ade6e408df30 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -120,6 +120,7 @@ Class | Method | HTTP request | Description *DownloadApi* | [**downloadFile**](doc//DownloadApi.md#downloadfile) | **POST** /download/asset/{id} | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicateApi* | [**getAssetDuplicates**](doc//DuplicateApi.md#getassetduplicates) | **GET** /duplicates | +*DuplicateApi* | [**resolveDuplicates**](doc//DuplicateApi.md#resolveduplicates) | **POST** /duplicates/resolve | *FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /face | *FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} | *FileReportApi* | [**fixAuditFiles**](doc//FileReportApi.md#fixauditfiles) | **POST** /report/fix | @@ -337,6 +338,7 @@ Class | Method | HTTP request | Description - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) - [RecognitionConfig](doc//RecognitionConfig.md) + - [ResolveDuplicatesDto](doc//ResolveDuplicatesDto.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) - [ScanLibraryDto](doc//ScanLibraryDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) diff --git a/mobile/openapi/doc/DuplicateApi.md b/mobile/openapi/doc/DuplicateApi.md index cdf279b69ac66..0f4b8dbd3c3d6 100644 --- a/mobile/openapi/doc/DuplicateApi.md +++ b/mobile/openapi/doc/DuplicateApi.md @@ -10,6 +10,7 @@ All URIs are relative to */api* Method | HTTP request | Description ------------- | ------------- | ------------- [**getAssetDuplicates**](DuplicateApi.md#getassetduplicates) | **GET** /duplicates | +[**resolveDuplicates**](DuplicateApi.md#resolveduplicates) | **POST** /duplicates/resolve | # **getAssetDuplicates** @@ -63,3 +64,57 @@ This endpoint does not need any parameter. [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **resolveDuplicates** +> resolveDuplicates(resolveDuplicatesDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = DuplicateApi(); +final resolveDuplicatesDto = ResolveDuplicatesDto(); // ResolveDuplicatesDto | + +try { + api_instance.resolveDuplicates(resolveDuplicatesDto); +} catch (e) { + print('Exception when calling DuplicateApi->resolveDuplicates: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **resolveDuplicatesDto** | [**ResolveDuplicatesDto**](ResolveDuplicatesDto.md)| | + +### Return type + +void (empty response body) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: Not defined + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/mobile/openapi/doc/ResolveDuplicatesDto.md b/mobile/openapi/doc/ResolveDuplicatesDto.md new file mode 100644 index 0000000000000..11f7f38b8e9ac --- /dev/null +++ b/mobile/openapi/doc/ResolveDuplicatesDto.md @@ -0,0 +1,16 @@ +# openapi.model.ResolveDuplicatesDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**duplicateId** | **String** | | +**ids** | **List** | | [default to const []] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 110c4f757e727..13f5654b01ed3 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -169,6 +169,7 @@ part 'model/queue_status_dto.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; part 'model/recognition_config.dart'; +part 'model/resolve_duplicates_dto.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; part 'model/scan_library_dto.dart'; part 'model/search_album_response_dto.dart'; diff --git a/mobile/openapi/lib/api/duplicate_api.dart b/mobile/openapi/lib/api/duplicate_api.dart index ef71108b86bf6..1cce0ff43c573 100644 --- a/mobile/openapi/lib/api/duplicate_api.dart +++ b/mobile/openapi/lib/api/duplicate_api.dart @@ -59,4 +59,43 @@ class DuplicateApi { } return null; } + + /// Performs an HTTP 'POST /duplicates/resolve' operation and returns the [Response]. + /// Parameters: + /// + /// * [ResolveDuplicatesDto] resolveDuplicatesDto (required): + Future resolveDuplicatesWithHttpInfo(ResolveDuplicatesDto resolveDuplicatesDto,) async { + // ignore: prefer_const_declarations + final path = r'/duplicates/resolve'; + + // ignore: prefer_final_locals + Object? postBody = resolveDuplicatesDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [ResolveDuplicatesDto] resolveDuplicatesDto (required): + Future resolveDuplicates(ResolveDuplicatesDto resolveDuplicatesDto,) async { + final response = await resolveDuplicatesWithHttpInfo(resolveDuplicatesDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 6256d0c4871bf..30637e5d631ee 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -406,6 +406,8 @@ class ApiClient { return ReactionTypeTypeTransformer().decode(value); case 'RecognitionConfig': return RecognitionConfig.fromJson(value); + case 'ResolveDuplicatesDto': + return ResolveDuplicatesDto.fromJson(value); case 'ReverseGeocodingStateResponseDto': return ReverseGeocodingStateResponseDto.fromJson(value); case 'ScanLibraryDto': diff --git a/mobile/openapi/lib/model/resolve_duplicates_dto.dart b/mobile/openapi/lib/model/resolve_duplicates_dto.dart new file mode 100644 index 0000000000000..550235fad5812 --- /dev/null +++ b/mobile/openapi/lib/model/resolve_duplicates_dto.dart @@ -0,0 +1,108 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class ResolveDuplicatesDto { + /// Returns a new [ResolveDuplicatesDto] instance. + ResolveDuplicatesDto({ + required this.duplicateId, + this.ids = const [], + }); + + String duplicateId; + + List ids; + + @override + bool operator ==(Object other) => identical(this, other) || other is ResolveDuplicatesDto && + other.duplicateId == duplicateId && + _deepEquality.equals(other.ids, ids); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (duplicateId.hashCode) + + (ids.hashCode); + + @override + String toString() => 'ResolveDuplicatesDto[duplicateId=$duplicateId, ids=$ids]'; + + Map toJson() { + final json = {}; + json[r'duplicateId'] = this.duplicateId; + json[r'ids'] = this.ids; + return json; + } + + /// Returns a new [ResolveDuplicatesDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ResolveDuplicatesDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return ResolveDuplicatesDto( + duplicateId: mapValueOfType(json, r'duplicateId')!, + ids: json[r'ids'] is Iterable + ? (json[r'ids'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ResolveDuplicatesDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = ResolveDuplicatesDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ResolveDuplicatesDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = ResolveDuplicatesDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'duplicateId', + 'ids', + }; +} + diff --git a/mobile/openapi/test/duplicate_api_test.dart b/mobile/openapi/test/duplicate_api_test.dart index 50a090bc3b390..ef71b58f22b87 100644 --- a/mobile/openapi/test/duplicate_api_test.dart +++ b/mobile/openapi/test/duplicate_api_test.dart @@ -22,5 +22,10 @@ void main() { // TODO }); + //Future resolveDuplicates(ResolveDuplicatesDto resolveDuplicatesDto) async + test('test resolveDuplicates', () async { + // TODO + }); + }); } diff --git a/mobile/openapi/test/resolve_duplicates_dto_test.dart b/mobile/openapi/test/resolve_duplicates_dto_test.dart new file mode 100644 index 0000000000000..e28bb409d7ccb --- /dev/null +++ b/mobile/openapi/test/resolve_duplicates_dto_test.dart @@ -0,0 +1,32 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for ResolveDuplicatesDto +void main() { + // final instance = ResolveDuplicatesDto(); + + group('test ResolveDuplicatesDto', () { + // String duplicateId + test('to test the property `duplicateId`', () async { + // TODO + }); + + // List ids (default value: const []) + test('to test the property `ids`', () async { + // TODO + }); + + + }); + +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 8fc5378edf8e1..f6418303cc06d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2256,6 +2256,41 @@ ] } }, + "/duplicates/resolve": { + "post": { + "operationId": "resolveDuplicates", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveDuplicatesDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Duplicate" + ] + } + }, "/face": { "get": { "operationId": "getFaces", @@ -9237,6 +9272,24 @@ ], "type": "object" }, + "ResolveDuplicatesDto": { + "properties": { + "duplicateId": { + "type": "string" + }, + "ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "duplicateId", + "ids" + ], + "type": "object" + }, "ReverseGeocodingStateResponseDto": { "properties": { "lastImportFileName": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f0af90a8dd3f3..18112dffe5f70 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -377,6 +377,10 @@ export type DuplicateResponseDto = { assets: AssetResponseDto[]; duplicateId: string; }; +export type ResolveDuplicatesDto = { + duplicateId: string; + ids: string[]; +}; export type PersonResponseDto = { birthDate: string | null; id: string; @@ -1708,6 +1712,15 @@ export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function resolveDuplicates({ resolveDuplicatesDto }: { + resolveDuplicatesDto: ResolveDuplicatesDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/duplicates/resolve", oazapfts.json({ + ...opts, + method: "POST", + body: resolveDuplicatesDto + }))); +} export function getFaces({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/duplicate.controller.ts b/server/src/controllers/duplicate.controller.ts index 57a200ac39061..ac86ab464b072 100644 --- a/server/src/controllers/duplicate.controller.ts +++ b/server/src/controllers/duplicate.controller.ts @@ -1,7 +1,7 @@ -import { Controller, Get } from '@nestjs/common'; +import { Body, Controller, Get, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; +import { DuplicateResponseDto, ResolveDuplicatesDto } from 'src/dtos/duplicate.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { DuplicateService } from 'src/services/duplicate.service'; @@ -15,4 +15,10 @@ export class DuplicateController { getAssetDuplicates(@Auth() auth: AuthDto): Promise { return this.service.getDuplicates(auth); } + + @Post('/resolve') + @Authenticated() + resolveDuplicates(@Auth() auth: AuthDto, @Body() dto: ResolveDuplicatesDto): Promise { + return this.service.resolveDuplicates(auth, dto); + } } diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts index cdfeed056f260..a7e23400c8a1c 100644 --- a/server/src/dtos/duplicate.dto.ts +++ b/server/src/dtos/duplicate.dto.ts @@ -1,3 +1,4 @@ +import { IsNotEmpty } from 'class-validator'; import { groupBy } from 'lodash'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; @@ -6,6 +7,14 @@ export class DuplicateResponseDto { assets!: AssetResponseDto[]; } +export class ResolveDuplicatesDto { + @IsNotEmpty() + duplicateId!: string; + + @IsNotEmpty() + ids!: string[]; +} + export function mapDuplicateResponse(assets: AssetResponseDto[]): DuplicateResponseDto[] { const result = []; diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 4560d9024c37a..80ff885b2f2ef 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,3 +1,4 @@ +import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -7,6 +8,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; +import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; @@ -25,6 +27,7 @@ describe(SearchService.name, () => { let loggerMock: Mocked; let cryptoMock: Mocked; let jobMock: Mocked; + let accessMock: Mocked; beforeEach(() => { assetMock = newAssetRepositoryMock(); @@ -33,8 +36,9 @@ describe(SearchService.name, () => { loggerMock = newLoggerRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); + accessMock = newAccessRepositoryMock(); - sut = new DuplicateService(systemMock, searchMock, assetMock, loggerMock, cryptoMock, jobMock); + sut = new DuplicateService(systemMock, searchMock, assetMock, loggerMock, cryptoMock, jobMock, accessMock); }); it('should work', () => { diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 95a12bd18ee00..26da03560f6aa 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,9 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; +import { AccessCore, Permission } from 'src/cores/access.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto'; +import { DuplicateResponseDto, ResolveDuplicatesDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto'; import { AssetEntity } from 'src/entities/asset.entity'; +import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { @@ -22,6 +24,7 @@ import { usePagination } from 'src/utils/pagination'; @Injectable() export class DuplicateService { + private access: AccessCore; private configCore: SystemConfigCore; constructor( @@ -31,9 +34,25 @@ export class DuplicateService { @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(IAccessRepository) accessRepository: IAccessRepository, ) { this.logger.setContext(DuplicateService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); + this.access = AccessCore.create(accessRepository); + } + + async resolveDuplicates(auth: AuthDto, dto: ResolveDuplicatesDto): Promise { + const { ids, duplicateId } = dto; + + await this.access.requirePermission(auth, Permission.ASSET_DELETE, ids); + + await this.assetRepository.softDeleteAll(dto.ids); + + await this.assetRepository.updateDuplicates({ + targetDuplicateId: null, + assetIds: ids, + duplicateIds: [duplicateId], + }); } async getDuplicates(auth: AuthDto): Promise { diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index ffc0e0152f6fc..f744d38560786 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -5,18 +5,11 @@ import { getAssetThumbnailUrl } from '$lib/utils'; import { asByteUnitString } from '$lib/utils/byte-units'; import { ThumbnailFormat, type AssetResponseDto, type DuplicateResponseDto } from '@immich/sdk'; - import { - mdiCheck, - mdiCheckOutline, - mdiImageCheckOutline, - mdiImageMultipleOutline, - mdiTrashCan, - mdiTrashCanOutline, - } from '@mdi/js'; + import { mdiCheck, mdiTrashCanOutline } from '@mdi/js'; import { onMount } from 'svelte'; export let duplicate: DuplicateResponseDto; - export let onResolve: (keepIds: string[], trashIds: string[]) => void; + export let onResolve: (trashIds: string[]) => void; let selectedAsset = new Set(); $: trashCount = duplicate.assets.length - selectedAsset.size; @@ -41,10 +34,9 @@ }; const handleOnResolve = () => { - const keepIds = Array.from(selectedAsset); - const trashIds = duplicate.assets.map((asset) => asset.id).filter((id) => !keepIds.includes(id)); + const trashIds = duplicate.assets.map((asset) => asset.id).filter((id) => !selectedAsset.has(id)); - onResolve(keepIds, trashIds); + onResolve(trashIds); }; diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte index f5ea13085ed92..e19160132b64a 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,15 +1,26 @@
{#each data.duplicates as duplicate (duplicate.duplicateId)} - + handleOnResolve(duplicate.duplicateId, ids)} /> {/each}
From dfdfff2d43ee037375a8ef1fe9c00c792c831898 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Mon, 20 May 2024 21:52:50 -0500 Subject: [PATCH 18/34] notification --- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte index e19160132b64a..5f45259e00eb7 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -5,12 +5,25 @@ import type { PageData } from './$types'; import { handleError } from '$lib/utils/handle-error'; + import { + NotificationType, + notificationController, + } from '$lib/components/shared-components/notification/notification'; export let data: PageData; const handleOnResolve = async (duplicateId: string, trashIds: string[]) => { try { await resolveDuplicates({ resolveDuplicatesDto: { duplicateId, ids: trashIds } }); data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); + + if (trashIds.length === 0) { + return; + } + + notificationController.show({ + message: `Moved ${trashIds.length} to trash`, + type: NotificationType.Info, + }); } catch (error) { handleError(error, 'Unable to resolve duplicate'); } From 824aff3eb87cd86cd259a5007b589e502f5d1470 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Tue, 21 May 2024 13:36:53 -0500 Subject: [PATCH 19/34] Single instance of duplication --- .../duplicates-compare-control.svelte | 34 +++++++++++++++---- .../[[assetId=id]]/+page.svelte | 21 +++++++++--- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index f744d38560786..ba47a4e7b1aef 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -4,7 +4,7 @@ import { locale } from '$lib/stores/preferences.store'; import { getAssetThumbnailUrl } from '$lib/utils'; import { asByteUnitString } from '$lib/utils/byte-units'; - import { ThumbnailFormat, type AssetResponseDto, type DuplicateResponseDto } from '@immich/sdk'; + import { ThumbnailFormat, type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk'; import { mdiCheck, mdiTrashCanOutline } from '@mdi/js'; import { onMount } from 'svelte'; @@ -12,7 +12,7 @@ export let onResolve: (trashIds: string[]) => void; let selectedAsset = new Set(); - $: trashCount = duplicate.assets.length - selectedAsset.size; + $: trashCount = duplicate ? duplicate.assets.length - selectedAsset.size : 0; onMount(() => { const suggestedAsset = duplicate.assets.sort( @@ -74,7 +74,7 @@
{asset.originalFileName}
{asset.exifInfo?.exifImageWidth}x{asset.exifInfo?.exifImageHeight} - {asByteUnitString( @@ -84,6 +84,28 @@ )}
{asset.libraryId ? 'In EXTERNAL Library' : 'In UPLOAD Library'}
+ {#await getAllAlbums({ assetId: asset.id })} + Scan for album... + {:then albums} + {#if albums.length === 0} + Not in any album + {:else} + In {albums.length} album{albums.length > 1 ? 's' : ''} + {/if} + {/await} +
{/each} @@ -91,18 +113,18 @@
-
+

DUPLICATE ID {duplicate.duplicateId}

TOTAL {duplicate.assets.length}

- {#if trashCount == 0} + {#if trashCount === 0} {:else} diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5f45259e00eb7..cf3623767b050 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -9,6 +9,7 @@ NotificationType, notificationController, } from '$lib/components/shared-components/notification/notification'; + export let data: PageData; const handleOnResolve = async (duplicateId: string, trashIds: string[]) => { @@ -31,9 +32,21 @@ -
- {#each data.duplicates as duplicate (duplicate.duplicateId)} - handleOnResolve(duplicate.duplicateId, ids)} /> - {/each} +
+ {#if data.duplicates && data.duplicates.length > 0} +
+

Resolve the following duplication by keeping or moving assets to the trash.

+
+ {#key data.duplicates[0].duplicateId} + handleOnResolve(data.duplicates[0].duplicateId, ids)} + /> + {/key} + {:else} +
+ No duplication was found on the instance +
+ {/if}
From 310fd72cda878039b60aeeb02eb0031007c5da79 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 21 May 2024 15:31:30 -0500 Subject: [PATCH 20/34] Update web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> --- .../machine-learning-settings/machine-learning-settings.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index b2f35f9f15d31..82917843e8209 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -80,7 +80,7 @@
Date: Tue, 21 May 2024 16:19:28 -0500 Subject: [PATCH 21/34] pr feedback --- server/src/dtos/duplicate.dto.ts | 2 +- server/src/services/duplicate.service.ts | 8 +++--- web/src/hooks.client.ts | 1 - .../admin-page/jobs/jobs-panel.svelte | 2 +- .../duplicates-compare-control.svelte | 28 +++++++++---------- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts index a7e23400c8a1c..89d6f61c7381f 100644 --- a/server/src/dtos/duplicate.dto.ts +++ b/server/src/dtos/duplicate.dto.ts @@ -12,7 +12,7 @@ export class ResolveDuplicatesDto { duplicateId!: string; @IsNotEmpty() - ids!: string[]; + assetIds!: string[]; } export function mapDuplicateResponse(assets: AssetResponseDto[]): DuplicateResponseDto[] { diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 26da03560f6aa..db8f065b24227 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -42,15 +42,15 @@ export class DuplicateService { } async resolveDuplicates(auth: AuthDto, dto: ResolveDuplicatesDto): Promise { - const { ids, duplicateId } = dto; + const { assetIds, duplicateId } = dto; - await this.access.requirePermission(auth, Permission.ASSET_DELETE, ids); + await this.access.requirePermission(auth, Permission.ASSET_DELETE, assetIds); - await this.assetRepository.softDeleteAll(dto.ids); + await this.assetRepository.softDeleteAll(assetIds); await this.assetRepository.updateDuplicates({ targetDuplicateId: null, - assetIds: ids, + assetIds, duplicateIds: [duplicateId], }); } diff --git a/web/src/hooks.client.ts b/web/src/hooks.client.ts index 7bc2aec00b208..e78f21a5f2801 100644 --- a/web/src/hooks.client.ts +++ b/web/src/hooks.client.ts @@ -31,7 +31,6 @@ const parseError = (error: unknown, status: number, message: string) => { }; export const handleError: HandleClientError = ({ error, status, message }) => { - console.log(error); const result = parseError(error, status, message); console.error(`[hooks.client.ts]:handleError ${result.message}`); return result; diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index e2ac36f29f53b..c7b177b05a459 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -92,7 +92,7 @@ [JobName.DuplicateDetection]: { icon: mdiContentDuplicate, title: getJobName(JobName.DuplicateDetection), - subtitle: 'Run machine learning on assets to detect similar images', + subtitle: 'Run machine learning on assets to detect similar images. Relies on Smart Search', disabled: !$featureFlags.duplicateDetection, }, [JobName.FaceDetection]: { diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index ba47a4e7b1aef..2d5b99827aee4 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -11,30 +11,30 @@ export let duplicate: DuplicateResponseDto; export let onResolve: (trashIds: string[]) => void; - let selectedAsset = new Set(); - $: trashCount = duplicate ? duplicate.assets.length - selectedAsset.size : 0; + let selectedAssetIds = new Set(); + $: trashCount = duplicate.assets.length - selectedAssetIds.size; onMount(() => { const suggestedAsset = duplicate.assets.sort( (a, b) => b.exifInfo!.fileSizeInByte! - a.exifInfo!.fileSizeInByte!, )[0]; - selectedAsset.add(suggestedAsset.id); - selectedAsset = new Set(selectedAsset); + selectedAssetIds.add(suggestedAsset.id); + selectedAssetIds = new Set(selectedAssetIds); }); const onSelectAsset = (asset: AssetResponseDto) => { - if (selectedAsset.has(asset.id)) { - selectedAsset.delete(asset.id); + if (selectedAssetIds.has(asset.id)) { + selectedAssetIds.delete(asset.id); } else { - selectedAsset.add(asset.id); + selectedAssetIds.add(asset.id); } - selectedAsset = new Set(selectedAsset); + selectedAssetIds = selectedAssetIds; }; - const handleOnResolve = () => { - const trashIds = duplicate.assets.map((asset) => asset.id).filter((id) => !selectedAsset.has(id)); + const handleResolve = () => { + const trashIds = duplicate.assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id)); onResolve(trashIds); }; @@ -43,7 +43,7 @@
{#each duplicate.assets as asset, index (index)} - {@const isSelected = selectedAsset.has(asset.id)} + {@const isSelected = selectedAssetIds.has(asset.id)}
{#if trashCount === 0} - {:else} - @@ -73,22 +97,11 @@ > {asset.originalFileName} - - {asset.exifInfo?.exifImageWidth}x{asset.exifInfo?.exifImageHeight} - {asByteUnitString( - Number(asset.exifInfo?.fileSizeInByte), - $locale, - 4, - )} - - {asset.libraryId ? 'In EXTERNAL Library' : 'In UPLOAD Library'} + {dimensions} - {fileSize} Date: Wed, 22 May 2024 10:57:05 -0500 Subject: [PATCH 24/34] using s --- .../duplicates/duplicates-compare-control.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 3d53ccf16182c..72921adea7c52 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -7,6 +7,7 @@ import { ThumbnailFormat, type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk'; import { mdiCheck, mdiTrashCanOutline } from '@mdi/js'; import { onMount } from 'svelte'; + import { s } from '$lib/utils'; export let duplicate: DuplicateResponseDto; export let onResolve: (trashIds: string[]) => void; @@ -114,7 +115,7 @@ {#if albums.length === 0} Not in any album {:else} - In {albums.length} album{albums.length > 1 ? 's' : ''} + In {albums.length} album{s(albums.length)} {/if} {/await} From bc6b055a48236d605a76d90762d3c0fdaa90c845 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Wed, 22 May 2024 11:36:18 -0500 Subject: [PATCH 25/34] controller signature --- mobile/openapi/README.md | 2 +- mobile/openapi/doc/DuplicateApi.md | 8 ++++--- mobile/openapi/doc/ResolveDuplicatesDto.md | 1 - mobile/openapi/lib/api/duplicate_api.dart | 15 ++++++++----- .../lib/model/resolve_duplicates_dto.dart | 14 +++--------- mobile/openapi/test/duplicate_api_test.dart | 2 +- .../test/resolve_duplicates_dto_test.dart | 5 ----- open-api/immich-openapi-specs.json | 22 ++++++++++++------- open-api/typescript-sdk/src/fetch-client.ts | 6 ++--- .../src/controllers/duplicate.controller.ts | 14 ++++++++---- server/src/dtos/duplicate.dto.ts | 3 --- server/src/services/duplicate.service.ts | 4 ++-- .../[[assetId=id]]/+page.svelte | 3 ++- 13 files changed, 51 insertions(+), 48 deletions(-) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 6baf4343e366b..27bdff9ec6d6f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -121,7 +121,7 @@ Class | Method | HTTP request | Description *DownloadApi* | [**downloadFile**](doc//DownloadApi.md#downloadfile) | **POST** /download/asset/{id} | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicateApi* | [**getAssetDuplicates**](doc//DuplicateApi.md#getassetduplicates) | **GET** /duplicates | -*DuplicateApi* | [**resolveDuplicates**](doc//DuplicateApi.md#resolveduplicates) | **POST** /duplicates/resolve | +*DuplicateApi* | [**resolveDuplicates**](doc//DuplicateApi.md#resolveduplicates) | **POST** /duplicates/{id}/resolve | *FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /face | *FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} | *FileReportApi* | [**fixAuditFiles**](doc//FileReportApi.md#fixauditfiles) | **POST** /report/fix | diff --git a/mobile/openapi/doc/DuplicateApi.md b/mobile/openapi/doc/DuplicateApi.md index 0f4b8dbd3c3d6..216ca527922eb 100644 --- a/mobile/openapi/doc/DuplicateApi.md +++ b/mobile/openapi/doc/DuplicateApi.md @@ -10,7 +10,7 @@ All URIs are relative to */api* Method | HTTP request | Description ------------- | ------------- | ------------- [**getAssetDuplicates**](DuplicateApi.md#getassetduplicates) | **GET** /duplicates | -[**resolveDuplicates**](DuplicateApi.md#resolveduplicates) | **POST** /duplicates/resolve | +[**resolveDuplicates**](DuplicateApi.md#resolveduplicates) | **POST** /duplicates/{id}/resolve | # **getAssetDuplicates** @@ -65,7 +65,7 @@ This endpoint does not need any parameter. [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **resolveDuplicates** -> resolveDuplicates(resolveDuplicatesDto) +> resolveDuplicates(id, resolveDuplicatesDto) @@ -88,10 +88,11 @@ import 'package:openapi/api.dart'; //defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); final api_instance = DuplicateApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final resolveDuplicatesDto = ResolveDuplicatesDto(); // ResolveDuplicatesDto | try { - api_instance.resolveDuplicates(resolveDuplicatesDto); + api_instance.resolveDuplicates(id, resolveDuplicatesDto); } catch (e) { print('Exception when calling DuplicateApi->resolveDuplicates: $e\n'); } @@ -101,6 +102,7 @@ try { Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- + **id** | **String**| | **resolveDuplicatesDto** | [**ResolveDuplicatesDto**](ResolveDuplicatesDto.md)| | ### Return type diff --git a/mobile/openapi/doc/ResolveDuplicatesDto.md b/mobile/openapi/doc/ResolveDuplicatesDto.md index 8b63480c8e0a4..260e8bcca6cf1 100644 --- a/mobile/openapi/doc/ResolveDuplicatesDto.md +++ b/mobile/openapi/doc/ResolveDuplicatesDto.md @@ -9,7 +9,6 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **assetIds** | **List** | | [default to const []] -**duplicateId** | **String** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/api/duplicate_api.dart b/mobile/openapi/lib/api/duplicate_api.dart index 1cce0ff43c573..8cbb82d6e7222 100644 --- a/mobile/openapi/lib/api/duplicate_api.dart +++ b/mobile/openapi/lib/api/duplicate_api.dart @@ -60,13 +60,16 @@ class DuplicateApi { return null; } - /// Performs an HTTP 'POST /duplicates/resolve' operation and returns the [Response]. + /// Performs an HTTP 'POST /duplicates/{id}/resolve' operation and returns the [Response]. /// Parameters: /// + /// * [String] id (required): + /// /// * [ResolveDuplicatesDto] resolveDuplicatesDto (required): - Future resolveDuplicatesWithHttpInfo(ResolveDuplicatesDto resolveDuplicatesDto,) async { + Future resolveDuplicatesWithHttpInfo(String id, ResolveDuplicatesDto resolveDuplicatesDto,) async { // ignore: prefer_const_declarations - final path = r'/duplicates/resolve'; + final path = r'/duplicates/{id}/resolve' + .replaceAll('{id}', id); // ignore: prefer_final_locals Object? postBody = resolveDuplicatesDto; @@ -91,9 +94,11 @@ class DuplicateApi { /// Parameters: /// + /// * [String] id (required): + /// /// * [ResolveDuplicatesDto] resolveDuplicatesDto (required): - Future resolveDuplicates(ResolveDuplicatesDto resolveDuplicatesDto,) async { - final response = await resolveDuplicatesWithHttpInfo(resolveDuplicatesDto,); + Future resolveDuplicates(String id, ResolveDuplicatesDto resolveDuplicatesDto,) async { + final response = await resolveDuplicatesWithHttpInfo(id, resolveDuplicatesDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/resolve_duplicates_dto.dart b/mobile/openapi/lib/model/resolve_duplicates_dto.dart index d92e136254ec6..3f961904e486b 100644 --- a/mobile/openapi/lib/model/resolve_duplicates_dto.dart +++ b/mobile/openapi/lib/model/resolve_duplicates_dto.dart @@ -14,31 +14,25 @@ class ResolveDuplicatesDto { /// Returns a new [ResolveDuplicatesDto] instance. ResolveDuplicatesDto({ this.assetIds = const [], - required this.duplicateId, }); List assetIds; - String duplicateId; - @override bool operator ==(Object other) => identical(this, other) || other is ResolveDuplicatesDto && - _deepEquality.equals(other.assetIds, assetIds) && - other.duplicateId == duplicateId; + _deepEquality.equals(other.assetIds, assetIds); @override int get hashCode => // ignore: unnecessary_parenthesis - (assetIds.hashCode) + - (duplicateId.hashCode); + (assetIds.hashCode); @override - String toString() => 'ResolveDuplicatesDto[assetIds=$assetIds, duplicateId=$duplicateId]'; + String toString() => 'ResolveDuplicatesDto[assetIds=$assetIds]'; Map toJson() { final json = {}; json[r'assetIds'] = this.assetIds; - json[r'duplicateId'] = this.duplicateId; return json; } @@ -53,7 +47,6 @@ class ResolveDuplicatesDto { assetIds: json[r'assetIds'] is Iterable ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) : const [], - duplicateId: mapValueOfType(json, r'duplicateId')!, ); } return null; @@ -102,7 +95,6 @@ class ResolveDuplicatesDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'assetIds', - 'duplicateId', }; } diff --git a/mobile/openapi/test/duplicate_api_test.dart b/mobile/openapi/test/duplicate_api_test.dart index ef71b58f22b87..eb0310b4a0bc8 100644 --- a/mobile/openapi/test/duplicate_api_test.dart +++ b/mobile/openapi/test/duplicate_api_test.dart @@ -22,7 +22,7 @@ void main() { // TODO }); - //Future resolveDuplicates(ResolveDuplicatesDto resolveDuplicatesDto) async + //Future resolveDuplicates(String id, ResolveDuplicatesDto resolveDuplicatesDto) async test('test resolveDuplicates', () async { // TODO }); diff --git a/mobile/openapi/test/resolve_duplicates_dto_test.dart b/mobile/openapi/test/resolve_duplicates_dto_test.dart index bcd50b72d999e..bc2859b30d9b0 100644 --- a/mobile/openapi/test/resolve_duplicates_dto_test.dart +++ b/mobile/openapi/test/resolve_duplicates_dto_test.dart @@ -21,11 +21,6 @@ void main() { // TODO }); - // String duplicateId - test('to test the property `duplicateId`', () async { - // TODO - }); - }); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index afa91ec0f9c63..fe2c51b6d007c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2256,10 +2256,20 @@ ] } }, - "/duplicates/resolve": { + "/duplicates/{id}/resolve": { "post": { "operationId": "resolveDuplicates", - "parameters": [], + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -2271,7 +2281,7 @@ "required": true }, "responses": { - "201": { + "200": { "description": "" } }, @@ -9291,14 +9301,10 @@ "type": "string" }, "type": "array" - }, - "duplicateId": { - "type": "string" } }, "required": [ - "assetIds", - "duplicateId" + "assetIds" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7a59189f079a1..a4aef546dbedb 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -379,7 +379,6 @@ export type DuplicateResponseDto = { }; export type ResolveDuplicatesDto = { assetIds: string[]; - duplicateId: string; }; export type PersonResponseDto = { birthDate: string | null; @@ -1710,10 +1709,11 @@ export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { ...opts })); } -export function resolveDuplicates({ resolveDuplicatesDto }: { +export function resolveDuplicates({ id, resolveDuplicatesDto }: { + id: string; resolveDuplicatesDto: ResolveDuplicatesDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/duplicates/resolve", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText(`/duplicates/${encodeURIComponent(id)}/resolve`, oazapfts.json({ ...opts, method: "POST", body: resolveDuplicatesDto diff --git a/server/src/controllers/duplicate.controller.ts b/server/src/controllers/duplicate.controller.ts index ac86ab464b072..e8d194295e244 100644 --- a/server/src/controllers/duplicate.controller.ts +++ b/server/src/controllers/duplicate.controller.ts @@ -1,9 +1,10 @@ -import { Body, Controller, Get, Post } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto, ResolveDuplicatesDto } from 'src/dtos/duplicate.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { DuplicateService } from 'src/services/duplicate.service'; +import { UUIDParamDto } from '../validation'; @ApiTags('Duplicate') @Controller('duplicates') @@ -16,9 +17,14 @@ export class DuplicateController { return this.service.getDuplicates(auth); } - @Post('/resolve') + @Post(':id/resolve') + @HttpCode(200) @Authenticated() - resolveDuplicates(@Auth() auth: AuthDto, @Body() dto: ResolveDuplicatesDto): Promise { - return this.service.resolveDuplicates(auth, dto); + resolveDuplicates( + @Auth() auth: AuthDto, + @Param() { id: duplicateId }: UUIDParamDto, + @Body() dto: ResolveDuplicatesDto, + ): Promise { + return this.service.resolveDuplicates(auth, duplicateId, dto); } } diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts index 89d6f61c7381f..8a3ca20355c09 100644 --- a/server/src/dtos/duplicate.dto.ts +++ b/server/src/dtos/duplicate.dto.ts @@ -8,9 +8,6 @@ export class DuplicateResponseDto { } export class ResolveDuplicatesDto { - @IsNotEmpty() - duplicateId!: string; - @IsNotEmpty() assetIds!: string[]; } diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index db8f065b24227..d67aab7919acc 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -41,8 +41,8 @@ export class DuplicateService { this.access = AccessCore.create(accessRepository); } - async resolveDuplicates(auth: AuthDto, dto: ResolveDuplicatesDto): Promise { - const { assetIds, duplicateId } = dto; + async resolveDuplicates(auth: AuthDto, duplicateId: string, dto: ResolveDuplicatesDto): Promise { + const { assetIds } = dto; await this.access.requirePermission(auth, Permission.ASSET_DELETE, assetIds); diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3c078b70908f8..ca4a0ad3a8e7d 100644 --- a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -14,7 +14,8 @@ const handleOnResolve = async (duplicateId: string, trashIds: string[]) => { try { - await resolveDuplicates({ resolveDuplicatesDto: { duplicateId, assetIds: trashIds } }); + await resolveDuplicates({ id: duplicateId, resolveDuplicatesDto: { assetIds: trashIds } }); + data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); if (trashIds.length === 0) { From 91e577eb047b17deb10278baef769b372604e11a Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Wed, 22 May 2024 11:44:36 -0500 Subject: [PATCH 26/34] linting --- server/src/controllers/duplicate.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/controllers/duplicate.controller.ts b/server/src/controllers/duplicate.controller.ts index e8d194295e244..f25fdbd55650a 100644 --- a/server/src/controllers/duplicate.controller.ts +++ b/server/src/controllers/duplicate.controller.ts @@ -4,7 +4,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto, ResolveDuplicatesDto } from 'src/dtos/duplicate.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { DuplicateService } from 'src/services/duplicate.service'; -import { UUIDParamDto } from '../validation'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('Duplicate') @Controller('duplicates') From aea6909086ca9f7e340ce9f35e11e24e1f182bae Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Wed, 22 May 2024 12:57:15 -0500 Subject: [PATCH 27/34] remove generated file from pr --- mobile/openapi/doc/DuplicateApi.md | 122 --------------------- mobile/openapi/doc/ResolveDuplicatesDto.md | 15 --- 2 files changed, 137 deletions(-) delete mode 100644 mobile/openapi/doc/DuplicateApi.md delete mode 100644 mobile/openapi/doc/ResolveDuplicatesDto.md diff --git a/mobile/openapi/doc/DuplicateApi.md b/mobile/openapi/doc/DuplicateApi.md deleted file mode 100644 index 216ca527922eb..0000000000000 --- a/mobile/openapi/doc/DuplicateApi.md +++ /dev/null @@ -1,122 +0,0 @@ -# openapi.api.DuplicateApi - -## Load the API package -```dart -import 'package:openapi/api.dart'; -``` - -All URIs are relative to */api* - -Method | HTTP request | Description -------------- | ------------- | ------------- -[**getAssetDuplicates**](DuplicateApi.md#getassetduplicates) | **GET** /duplicates | -[**resolveDuplicates**](DuplicateApi.md#resolveduplicates) | **POST** /duplicates/{id}/resolve | - - -# **getAssetDuplicates** -> List getAssetDuplicates() - - - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = DuplicateApi(); - -try { - final result = api_instance.getAssetDuplicates(); - print(result); -} catch (e) { - print('Exception when calling DuplicateApi->getAssetDuplicates: $e\n'); -} -``` - -### Parameters -This endpoint does not need any parameter. - -### Return type - -[**List**](DuplicateResponseDto.md) - -### Authorization - -[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **resolveDuplicates** -> resolveDuplicates(id, resolveDuplicatesDto) - - - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = DuplicateApi(); -final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | -final resolveDuplicatesDto = ResolveDuplicatesDto(); // ResolveDuplicatesDto | - -try { - api_instance.resolveDuplicates(id, resolveDuplicatesDto); -} catch (e) { - print('Exception when calling DuplicateApi->resolveDuplicates: $e\n'); -} -``` - -### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **id** | **String**| | - **resolveDuplicatesDto** | [**ResolveDuplicatesDto**](ResolveDuplicatesDto.md)| | - -### Return type - -void (empty response body) - -### Authorization - -[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: Not defined - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - diff --git a/mobile/openapi/doc/ResolveDuplicatesDto.md b/mobile/openapi/doc/ResolveDuplicatesDto.md deleted file mode 100644 index 260e8bcca6cf1..0000000000000 --- a/mobile/openapi/doc/ResolveDuplicatesDto.md +++ /dev/null @@ -1,15 +0,0 @@ -# openapi.model.ResolveDuplicatesDto - -## Load the model package -```dart -import 'package:openapi/api.dart'; -``` - -## Properties -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**assetIds** | **List** | | [default to const []] - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - From 5bca35a98eed3ade7329d4bd1aef3eed702a7eb1 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Wed, 22 May 2024 16:27:56 -0500 Subject: [PATCH 28/34] refactor --- .../duplicates-compare-control.svelte | 18 +++--------------- web/src/lib/utils/asset-utils.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 72921adea7c52..f8fbbfd76fc69 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -8,6 +8,7 @@ import { mdiCheck, mdiTrashCanOutline } from '@mdi/js'; import { onMount } from 'svelte'; import { s } from '$lib/utils'; + import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils'; export let duplicate: DuplicateResponseDto; export let onResolve: (trashIds: string[]) => void; @@ -52,14 +53,6 @@ {@const isSelected = selectedAssetIds.has(asset.id)} {@const isFromExternalLibrary = !!asset.libraryId} {@const assetData = JSON.stringify(asset, null, 2)} - {@const hasValidFileSize = asset.exifInfo?.fileSizeInByte != null} - {@const hasValidDimensions = asset.exifInfo?.exifImageWidth != null && asset.exifInfo?.exifImageHeight != null} - {@const fileSize = hasValidFileSize - ? asByteUnitString(Number(asset.exifInfo?.fileSizeInByte), $locale, 4) - : 'Invalid Data'} - {@const dimensions = hasValidDimensions - ? `${asset.exifInfo?.exifImageWidth}x${asset.exifInfo?.exifImageHeight}` - : 'Invalid Data'}