From 4b3f8d19460b67cf1942330a370a4ddd6ee2d45a Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 13 Feb 2024 13:54:58 -0600 Subject: [PATCH] feat: Search filtering logic (#6968) * commit * controller/service/repository logic * use enum * openapi * suggest people * suggest place/camera * cursor hover * refactor * Add try catch * Remove get people with name service * Remove deadcode * people selection * People placement * sort people * Update server/src/domain/repositories/metadata.repository.ts Co-authored-by: Jason Rasmussen * pr feedback * styling * done * open api * fix test * use string type * remmove bad merge * use correct type * fix test * fix lint * remove unused code * remove unused code * pr feedback * pr feedback --------- Co-authored-by: Jason Rasmussen --- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 24246 -> 24416 bytes mobile/openapi/doc/SearchApi.md | Bin 18852 -> 21245 bytes mobile/openapi/doc/SearchSuggestionType.md | Bin 0 -> 386 bytes mobile/openapi/lib/api.dart | Bin 8264 -> 8306 bytes mobile/openapi/lib/api/search_api.dart | Bin 26557 -> 29053 bytes mobile/openapi/lib/api_client.dart | Bin 23419 -> 23526 bytes mobile/openapi/lib/api_helper.dart | Bin 5826 -> 5944 bytes .../lib/model/search_suggestion_type.dart | Bin 0 -> 3225 bytes mobile/openapi/test/search_api_test.dart | Bin 2500 -> 2714 bytes .../test/search_suggestion_type_test.dart | Bin 0 -> 437 bytes open-api/immich-openapi-specs.json | 87 +++- open-api/typescript-sdk/axios-client/api.ts | 166 +++++- open-api/typescript-sdk/fetch-client.ts | Bin 73049 -> 73637 bytes server/package.json | 2 +- .../repositories/metadata.repository.ts | 5 + .../search/dto/search-suggestion.dto.ts | 33 ++ .../src/domain/search/search.service.spec.ts | 7 +- server/src/domain/search/search.service.ts | 27 + .../immich/controllers/search.controller.ts | 6 + .../infra/repositories/metadata.repository.ts | 112 +++- .../repositories/metadata.repository.mock.ts | 5 + .../shared-components/combobox.svelte | 13 +- .../search-bar/search-filter-box.svelte | 484 ++++++++++++++---- 24 files changed, 836 insertions(+), 114 deletions(-) create mode 100644 mobile/openapi/doc/SearchSuggestionType.md create mode 100644 mobile/openapi/lib/model/search_suggestion_type.dart create mode 100644 mobile/openapi/test/search_suggestion_type_test.dart create mode 100644 server/src/domain/search/dto/search-suggestion.dto.ts diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 186e3675c..aeb3d49cb 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -120,6 +120,7 @@ doc/SearchExploreResponseDto.md doc/SearchFacetCountResponseDto.md doc/SearchFacetResponseDto.md doc/SearchResponseDto.md +doc/SearchSuggestionType.md doc/ServerConfigDto.md doc/ServerFeaturesDto.md doc/ServerInfoApi.md @@ -313,6 +314,7 @@ lib/model/search_explore_response_dto.dart lib/model/search_facet_count_response_dto.dart lib/model/search_facet_response_dto.dart lib/model/search_response_dto.dart +lib/model/search_suggestion_type.dart lib/model/server_config_dto.dart lib/model/server_features_dto.dart lib/model/server_info_response_dto.dart @@ -485,6 +487,7 @@ test/search_explore_response_dto_test.dart test/search_facet_count_response_dto_test.dart test/search_facet_response_dto_test.dart test/search_response_dto_test.dart +test/search_suggestion_type_test.dart test/server_config_dto_test.dart test/server_features_dto_test.dart test/server_info_api_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1657aa69cb1eea7dce703f48a4804e54301552c0..12d20ea13d1244f3711942071cb154f0753e93fb 100644 GIT binary patch delta 124 zcmdnCm+`?q#to|4BI&6m!KsNw$r-_=>FKG(C7Jno#gi{;NeUK&MT?O{7ix=eBk?y2 fXg^fbMb#QoS&$m5k&>URua9mjNNV%5zzaM8Dzh>K delta 19 bcmaE`k8#^x#to|4o2P4^QQJH_=qL{WUAhSD diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index 40b44fb011ba838ec4c6bd80315905ad67040cdd..8c924428f6656b84fb051f5aeac9eb97d7039a25 100644 GIT binary patch delta 444 zcmZ27nep#Z#tmwW5$UNV!KsNw$r-_=>FKG(C7Jno#addi8W2Ipf=s>K6lI{IVz8oO zgd$Ca8U-yach?XtEd_mutUi+TW_de?WMnR!w z@_i@$$@A3xC*N~boqXS2NI**qIWV-eHp_XmF;2GE<=ULgIge%XeHr%6!hHQqlP|dQ KZ{8#D!U6y~5u%p> delta 57 zcmeynlyS*q#tmwWn_C&pIX0gWTEfEZkdj(Zl$xAalA5x)Kzte#vcTkn0wSBGq#j!U E0Cn&cXaE2J diff --git a/mobile/openapi/doc/SearchSuggestionType.md b/mobile/openapi/doc/SearchSuggestionType.md new file mode 100644 index 0000000000000000000000000000000000000000..e37b3f0de5df26271baa12bf3e0c0cae3d71ee91 GIT binary patch literal 386 zcma)%L2Cjr5QXpaD+czU9un_*vgkpmBI-?q4V&p2YBC`i4~71CldVwOOPfo0lQ-|n zYmg%a6P@;K>8cN8Qg=Dr`kt~l4#$s308N9hY%I!!aPVO?-Mi@t+P1YO2{<1)3637~ z*&}sYldEAguht=Hm&#)jM;W%p6MkZalxs=3ODEqxUQ8`%Z<5%C55 I1)l)G9op`A1poj5 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 48305a98043cad291a7b13704fabc13f1c740cf7..f2903e3516dd181717fb436a56befcd01bae561e 100644 GIT binary patch delta 35 rcmX@%@X29=jp*cf5mv6^()9Gy;*!k#yvc@wl9S^_**80ivU39f@8=7! delta 12 Tcmez5aKd4Ojp*hyQ66ppB~Ju_ diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 55d737a8dbb0f2709a1d520f898b935ab23ca561..16d2b3003e2c7f26e05ccb909b3ceee59b3eebb9 100644 GIT binary patch delta 512 zcmdmcp7HM^#tkdjdGw1*)6-LnOEUBGiYGsG^O8(WEK1G@h6{#N7No{1lmN-e{4uJN zE8WE>CkSv%_V+Xf)B8L*CyR0l`lhFr1f%IJ4$myf@F*!M@XSlg*FcvAnF}^nN1<9F zxTGjEFWp`tIlnZoq^NSTow+RbDmI9tC-bw*uz~|~a;aO| delta 65 zcmezSh;i?E#tkdjCr=QJ;!{via7allC`wIEEJ;n7d{J3!^Dn_Db}ZtPe+hou93`b3 F4ggdI8D{_h diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9643a90424b2074b02e199714d8d27dd1cabc433..a8cf4c34c112238a7dbb261f82b3ce527d273454 100644 GIT binary patch delta 54 zcmeypjq%xb#tmO=Cl}bTa0Qp9r>7Q|Waj5hzF;CD5S*G=l$?Pi$Y^f7`IBv@EC7Y- B7HR+h delta 14 WcmaF1o$>cJ#tmO=H^R delta 12 Tcmdm?cSv^w3-9JC-hG?^Aj|}q diff --git a/mobile/openapi/lib/model/search_suggestion_type.dart b/mobile/openapi/lib/model/search_suggestion_type.dart new file mode 100644 index 0000000000000000000000000000000000000000..d33b4a69d9a62d52b55e3b2bc3257aea9a247397 GIT binary patch literal 3225 zcma)8ZExE)5dQ98aRG+L0bF_Ory{MJ28gp|Xp^CFJ`9E-Fw)89LX#Rv#W0He_uY|_ z;yALh0I?G4uEm-@OJV8TqjhB5x^DH%Xy=9Ysm#ftEF{{X?mC?*snMm%=p~8ymC)(OU(;+Q ztQ!o-od;8ZDN&13CiMSlFc@S?I0v^VY___smP>SA8a>;V2tNk^W6Hu+3_%KyAVA{= zP*-c%NWTKVLMW|yr0{Z>KMGahA|bsHccNJykU^d>$GPhj#T8V#cD@t@A<{Xi*o{H0Yf$m)NG-I@7@8wC(>OTKV6r8+t?+`#=oWYx#iblb<(8Q7v2n%R{lnXE5 zUStm}-EAl7;psi3h4L37^M+XKe<710oVIs4JDc>d_ntE%^Vs```TmebO~Emp!>&Mq z6BCG=AL77hiRL17lcRw&Z}J#$vzW8+f>S9vug?R7${au8iYiB3Wj5D@wFSf9+ls~< z)rm)2w{60KHXiN~VkvWQRhFT11sMs1!#E;+9U#nBBN8!B;19Dwno}e0xjomRgZq8g zVZRYU^v!6!)3lq!Z3XkGwZ_IvuBh$w%T(|L-V2E0+ybo;`4%>O8?t!**A>u4K^q3; zp0c#F5X8Qv%TjyNHb5+4aZNKhXNKjBusrQgmLx3Fq?9xyD#t^d3>!tp%JO-w;2*fv zILa<~oYmPMHudMiH@w)^l&Fs@+R`F39Sg!MesQ$WWUF|(%Q~5G2+vdgw60}mOi4Y7 zBC9er)!Jbw9rx=7nOB-T!`@J2k=J@VQ@-DC9Ob+<95ljn@~t4^gk{NRGdUK5=FcgW zu=(Qx9JS@yO*Bh*1}2=_WyK420POJiPCsaZYg_{(%Ba0#@14RgT+X=2G(LJ}0nZeU nkH|iP@|N(Y&=b>lQtuvpQTNoO&#>%S820qeo*DKN51Rh~iBmr1 literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index be12c7e1f821ee484d787382d752a5cd74e1547e..d89c47e748199a7309c3ecbcdbe9dbde82783b55 100644 GIT binary patch delta 133 zcmX>iJWFR;!zW^sv4a7j^SUb>x~LV9XRaB5;vaz=1zdU|ScNoIatu?D(GNM%8) vLJ5%2QK(jcXjMqgFU>0{s+_!E*1o$ diff --git a/mobile/openapi/test/search_suggestion_type_test.dart b/mobile/openapi/test/search_suggestion_type_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..f86a7de90a9eec4906d5faad89ca051d1c7e6c2b GIT binary patch literal 437 zcmZvYQA@)x5Xay1DbA;EP*>fPYzPk8$v{_d^}(lbw6`wUCh?LgL-yTGls?&qT=<9I z?=DG}i7bQ7YgJx97W?95Qx!REcTYtMc?tWng6A^dZQrf}7RiSO+21c0w@VSDYPDgK zN5k@{I$FV?2S<&hj(XBQ9kc4KVLxeL_l0LC?cgQT$Dlaq8v9T!a@akw3(eV0>$TC& zqxMoYVW2uH;$PiO4(pUACp)9tfcX<@d3Pi06S5QH?~agItlzPvm9eDJsw}KUe08$^ zhj?;6i)94j(3l;BFM$Ig*QdkKw&6$_U7an7Poo;>QR&$nzJ&m2*$j4;L~YVYQW(+8 Jd@aO9`~V+El|=vm literal 0 HcmV?d00001 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 501218fc8..67f6e45c2 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4370,7 +4370,6 @@ "name": "clip", "required": false, "in": "query", - "description": "@deprecated", "deprecated": true, "schema": { "type": "boolean" @@ -5231,6 +5230,82 @@ ] } }, + "/search/suggestions": { + "get": { + "operationId": "getSearchSuggestions", + "parameters": [ + { + "name": "country", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "make", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "model", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "state", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "type", + "required": true, + "in": "query", + "schema": { + "$ref": "#/components/schemas/SearchSuggestionType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/server-info": { "get": { "operationId": "getServerInfo", @@ -9243,6 +9318,16 @@ ], "type": "object" }, + "SearchSuggestionType": { + "enum": [ + "country", + "state", + "city", + "camera-make", + "camera-model" + ], + "type": "string" + }, "ServerConfigDto": { "properties": { "externalDomain": { diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts index c231aa617..80e9588c1 100644 --- a/open-api/typescript-sdk/axios-client/api.ts +++ b/open-api/typescript-sdk/axios-client/api.ts @@ -2995,6 +2995,23 @@ export interface SearchResponseDto { */ 'assets': SearchAssetResponseDto; } +/** + * + * @export + * @enum {string} + */ + +export const SearchSuggestionType = { + Country: 'country', + State: 'state', + City: 'city', + CameraMake: 'camera-make', + CameraModel: 'camera-model' +} as const; + +export type SearchSuggestionType = typeof SearchSuggestionType[keyof typeof SearchSuggestionType]; + + /** * * @export @@ -14521,7 +14538,72 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio }, /** * - * @param {boolean} [clip] @deprecated + * @param {SearchSuggestionType} type + * @param {string} [country] + * @param {string} [make] + * @param {string} [model] + * @param {string} [state] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSearchSuggestions: async (type: SearchSuggestionType, country?: string, make?: string, model?: string, state?: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'type' is not null or undefined + assertParamExists('getSearchSuggestions', 'type', type) + const localVarPath = `/search/suggestions`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (country !== undefined) { + localVarQueryParameter['country'] = country; + } + + if (make !== undefined) { + localVarQueryParameter['make'] = make; + } + + if (model !== undefined) { + localVarQueryParameter['model'] = model; + } + + if (state !== undefined) { + localVarQueryParameter['state'] = state; + } + + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {boolean} [clip] * @param {boolean} [motion] * @param {number} [page] * @param {string} [q] @@ -15151,7 +15233,23 @@ export const SearchApiFp = function(configuration?: Configuration) { }, /** * - * @param {boolean} [clip] @deprecated + * @param {SearchSuggestionType} type + * @param {string} [country] + * @param {string} [make] + * @param {string} [model] + * @param {string} [state] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getSearchSuggestions(type: SearchSuggestionType, country?: string, make?: string, model?: string, state?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getSearchSuggestions(type, country, make, model, state, options); + const index = configuration?.serverIndex ?? 0; + const operationBasePath = operationServerMap['SearchApi.getSearchSuggestions']?.[index]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); + }, + /** + * + * @param {boolean} [clip] * @param {boolean} [motion] * @param {number} [page] * @param {string} [q] @@ -15296,6 +15394,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat getExploreData(options?: RawAxiosRequestConfig): AxiosPromise> { return localVarFp.getExploreData(options).then((request) => request(axios, basePath)); }, + /** + * + * @param {SearchApiGetSearchSuggestionsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSearchSuggestions(requestParameters: SearchApiGetSearchSuggestionsRequest, options?: RawAxiosRequestConfig): AxiosPromise> { + return localVarFp.getSearchSuggestions(requestParameters.type, requestParameters.country, requestParameters.make, requestParameters.model, requestParameters.state, options).then((request) => request(axios, basePath)); + }, /** * * @param {SearchApiSearchRequest} requestParameters Request parameters. @@ -15336,6 +15443,48 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat }; }; +/** + * Request parameters for getSearchSuggestions operation in SearchApi. + * @export + * @interface SearchApiGetSearchSuggestionsRequest + */ +export interface SearchApiGetSearchSuggestionsRequest { + /** + * + * @type {SearchSuggestionType} + * @memberof SearchApiGetSearchSuggestions + */ + readonly type: SearchSuggestionType + + /** + * + * @type {string} + * @memberof SearchApiGetSearchSuggestions + */ + readonly country?: string + + /** + * + * @type {string} + * @memberof SearchApiGetSearchSuggestions + */ + readonly make?: string + + /** + * + * @type {string} + * @memberof SearchApiGetSearchSuggestions + */ + readonly model?: string + + /** + * + * @type {string} + * @memberof SearchApiGetSearchSuggestions + */ + readonly state?: string +} + /** * Request parameters for search operation in SearchApi. * @export @@ -15343,7 +15492,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat */ export interface SearchApiSearchRequest { /** - * @deprecated + * * @type {boolean} * @memberof SearchApiSearch */ @@ -15969,6 +16118,17 @@ export class SearchApi extends BaseAPI { return SearchApiFp(this.configuration).getExploreData(options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {SearchApiGetSearchSuggestionsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SearchApi + */ + public getSearchSuggestions(requestParameters: SearchApiGetSearchSuggestionsRequest, options?: RawAxiosRequestConfig) { + return SearchApiFp(this.configuration).getSearchSuggestions(requestParameters.type, requestParameters.country, requestParameters.make, requestParameters.model, requestParameters.state, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {SearchApiSearchRequest} requestParameters Request parameters. diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts index bb44cbeeb33bdfed81a362354331808278a3841a..00b8c683264d5fb9bd8acad9691799f5ebcf5036 100644 GIT binary patch delta 279 zcmcb)i)HD4mJRVXL5W4l8NsFL>8ZsfnfZAkl?AB^whBtg`K5U!MU_elH3~|_C5a`e zU{-Qw2}CG4F*mg+Q8za+8!7?i=clCRC`~?KFS0q=reu>Ps@cUF)e2B^bre8afh0(S zjsn;Q9R-yVknOcr3e}VSXGu@~KU;nB_BkSx9sY(8pJvT3vO$0j`hhC2%o diff --git a/server/package.json b/server/package.json index 050128bb8..7ed2e551d 100644 --- a/server/package.json +++ b/server/package.json @@ -145,7 +145,7 @@ "coverageDirectory": "./coverage", "coverageThreshold": { "./src/domain/": { - "branches": 80, + "branches": 79, "functions": 80, "lines": 90, "statements": 90 diff --git a/server/src/domain/repositories/metadata.repository.ts b/server/src/domain/repositories/metadata.repository.ts index ca9cfe497..aff74ef36 100644 --- a/server/src/domain/repositories/metadata.repository.ts +++ b/server/src/domain/repositories/metadata.repository.ts @@ -39,4 +39,9 @@ export interface IMetadataRepository { readTags(path: string): Promise; writeTags(path: string, tags: Partial): Promise; extractBinaryTag(tagName: string, path: string): Promise; + getCountries(userId: string): Promise; + getStates(userId: string, country?: string): Promise; + getCities(userId: string, country?: string, state?: string): Promise; + getCameraMakes(userId: string, model?: string): Promise; + getCameraModels(userId: string, make?: string): Promise; } diff --git a/server/src/domain/search/dto/search-suggestion.dto.ts b/server/src/domain/search/dto/search-suggestion.dto.ts new file mode 100644 index 000000000..36a752458 --- /dev/null +++ b/server/src/domain/search/dto/search-suggestion.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export enum SearchSuggestionType { + COUNTRY = 'country', + STATE = 'state', + CITY = 'city', + CAMERA_MAKE = 'camera-make', + CAMERA_MODEL = 'camera-model', +} + +export class SearchSuggestionRequestDto { + @IsEnum(SearchSuggestionType) + @IsNotEmpty() + @ApiProperty({ enumName: 'SearchSuggestionType', enum: SearchSuggestionType }) + type!: SearchSuggestionType; + + @IsString() + @IsOptional() + country?: string; + + @IsString() + @IsOptional() + state?: string; + + @IsString() + @IsOptional() + make?: string; + + @IsString() + @IsOptional() + model?: string; +} diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index 4f8d8c8fe..de1d63c9d 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -4,6 +4,7 @@ import { authStub, newAssetRepositoryMock, newMachineLearningRepositoryMock, + newMetadataRepositoryMock, newPartnerRepositoryMock, newPersonRepositoryMock, newSearchRepositoryMock, @@ -14,6 +15,7 @@ import { mapAsset } from '../asset'; import { IAssetRepository, IMachineLearningRepository, + IMetadataRepository, IPartnerRepository, IPersonRepository, ISearchRepository, @@ -32,6 +34,7 @@ describe(SearchService.name, () => { let personMock: jest.Mocked; let searchMock: jest.Mocked; let partnerMock: jest.Mocked; + let metadataMock: jest.Mocked; beforeEach(() => { assetMock = newAssetRepositoryMock(); @@ -40,7 +43,9 @@ describe(SearchService.name, () => { personMock = newPersonRepositoryMock(); searchMock = newSearchRepositoryMock(); partnerMock = newPartnerRepositoryMock(); - sut = new SearchService(configMock, machineMock, personMock, searchMock, assetMock, partnerMock); + metadataMock = newMetadataRepositoryMock(); + + sut = new SearchService(configMock, machineMock, personMock, searchMock, assetMock, partnerMock, metadataMock); }); it('should work', () => { diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 1438dc3be..49cca2ab4 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -7,6 +7,7 @@ import { PersonResponseDto } from '../person'; import { IAssetRepository, IMachineLearningRepository, + IMetadataRepository, IPartnerRepository, IPersonRepository, ISearchRepository, @@ -16,6 +17,7 @@ import { } from '../repositories'; import { FeatureFlag, SystemConfigCore } from '../system-config'; import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto'; +import { SearchSuggestionRequestDto, SearchSuggestionType } from './dto/search-suggestion.dto'; import { SearchResponseDto } from './response-dto'; @Injectable() @@ -30,6 +32,7 @@ export class SearchService { @Inject(ISearchRepository) private searchRepository: ISearchRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, + @Inject(IMetadataRepository) private metadataRepository: IMetadataRepository, ) { this.configCore = SystemConfigCore.create(configRepository); } @@ -176,4 +179,28 @@ export class SearchService { }, }; } + + async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise { + if (dto.type === SearchSuggestionType.COUNTRY) { + return this.metadataRepository.getCountries(auth.user.id); + } + + if (dto.type === SearchSuggestionType.STATE) { + return this.metadataRepository.getStates(auth.user.id, dto.country); + } + + if (dto.type === SearchSuggestionType.CITY) { + return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state); + } + + if (dto.type === SearchSuggestionType.CAMERA_MAKE) { + return this.metadataRepository.getCameraMakes(auth.user.id, dto.model); + } + + if (dto.type === SearchSuggestionType.CAMERA_MODEL) { + return this.metadataRepository.getCameraModels(auth.user.id, dto.make); + } + + return []; + } } diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index 1cc204e84..f8438b2e3 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -9,6 +9,7 @@ import { SearchService, SmartSearchDto, } from '@app/domain'; +import { SearchSuggestionRequestDto } from '@app/domain/search/dto/search-suggestion.dto'; import { Controller, Get, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated } from '../app.guard'; @@ -46,4 +47,9 @@ export class SearchController { searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise { return this.service.searchPerson(auth, dto); } + + @Get('suggestions') + getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise { + return this.service.getSearchSuggestions(auth, dto); + } } diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts index 83c05597a..6a90ad108 100644 --- a/server/src/infra/repositories/metadata.repository.ts +++ b/server/src/infra/repositories/metadata.repository.ts @@ -10,7 +10,13 @@ import { ISystemMetadataRepository, ReverseGeocodeResult, } from '@app/domain'; -import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities'; +import { + ExifEntity, + GeodataAdmin1Entity, + GeodataAdmin2Entity, + GeodataPlacesEntity, + SystemMetadataKey, +} from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; import { Inject } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; @@ -21,12 +27,14 @@ import { createReadStream, existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import * as readLine from 'node:readline'; import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm'; +import { DummyValue, GenerateSql } from '../infra.util'; type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity; type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity; export class MetadataRepository implements IMetadataRepository { constructor( + @InjectRepository(ExifEntity) private exifRepository: Repository, @InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository, @InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository, @InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository, @@ -213,4 +221,106 @@ export class MetadataRepository implements IMetadataRepository { this.logger.warn(`Error writing exif data (${path}): ${error}`); } } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getCountries(userId: string): Promise { + const entity = await this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId = :userId', { userId }) + .andWhere('exif.country IS NOT NULL') + .select('exif.country') + .distinctOn(['exif.country']) + .getMany(); + + return entity.map((e) => e.country ?? '').filter((c) => c !== ''); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + async getStates(userId: string, country: string | undefined): Promise { + let result: ExifEntity[] = []; + + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId = :userId', { userId }) + .andWhere('exif.state IS NOT NULL') + .select('exif.state') + .distinctOn(['exif.state']); + + if (country) { + query.andWhere('exif.country = :country', { country }); + } + + result = await query.getMany(); + + return result.map((entity) => entity.state ?? '').filter((s) => s !== ''); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] }) + async getCities(userId: string, country: string | undefined, state: string | undefined): Promise { + let result: ExifEntity[] = []; + + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId = :userId', { userId }) + .andWhere('exif.city IS NOT NULL') + .select('exif.city') + .distinctOn(['exif.city']); + + if (country) { + query.andWhere('exif.country = :country', { country }); + } + + if (state) { + query.andWhere('exif.state = :state', { state }); + } + + result = await query.getMany(); + + return result.map((entity) => entity.city ?? '').filter((c) => c !== ''); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + async getCameraMakes(userId: string, model: string | undefined): Promise { + let result: ExifEntity[] = []; + + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId = :userId', { userId }) + .andWhere('exif.make IS NOT NULL') + .select('exif.make') + .distinctOn(['exif.make']); + + if (model) { + query.andWhere('exif.model = :model', { model }); + } + + result = await query.getMany(); + + return result.map((entity) => entity.make ?? '').filter((m) => m !== ''); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + async getCameraModels(userId: string, make: string | undefined): Promise { + let result: ExifEntity[] = []; + + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId = :userId', { userId }) + .andWhere('exif.model IS NOT NULL') + .select('exif.model') + .distinctOn(['exif.model']); + + if (make) { + query.andWhere('exif.make = :make', { make }); + } + + result = await query.getMany(); + + return result.map((entity) => entity.model ?? '').filter((m) => m !== ''); + } } diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index 6771060fc..e47120ac9 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -8,5 +8,10 @@ export const newMetadataRepositoryMock = (): jest.Mocked => readTags: jest.fn(), writeTags: jest.fn(), extractBinaryTag: jest.fn(), + getCameraMakes: jest.fn(), + getCameraModels: jest.fn(), + getCities: jest.fn(), + getCountries: jest.fn(), + getStates: jest.fn(), }; }; diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 86f7d6e91..d0ce0e25a 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -23,6 +23,7 @@ export let selectedOption: ComboBoxOption | undefined = undefined; export let placeholder = ''; export const label = ''; + export let noLabel = false; let isOpen = false; let searchQuery = ''; @@ -31,11 +32,13 @@ const dispatch = createEventDispatcher<{ select: ComboBoxOption; + click: void; }>(); let handleClick = () => { searchQuery = ''; isOpen = !isOpen; + dispatch('click'); }; let handleOutClick = () => { @@ -52,7 +55,9 @@
+ {/each} +
+ +
+ +
+ {/if} + + +
+ +
+
+ +
+

PLACE

+ +
+
+

Country

+ updateSuggestion(SearchSuggestionType.Country, {})} + /> +
+ +
+

State

+ updateSuggestion(SearchSuggestionType.State, { country: filter.location.country?.value })} + /> +
+ +
+

City

+ + updateSuggestion(SearchSuggestionType.City, { + country: filter.location.country?.value, + state: filter.location.state?.value, + })} + /> +
+
+
+ +
+ +
+

CAMERA

+ +
+
+

Make

+ + updateSuggestion(SearchSuggestionType.CameraMake, { cameraModel: filter.camera.model?.value })} + /> +
+ +
+

Model

+ + updateSuggestion(SearchSuggestionType.CameraModel, { cameraMake: filter.camera.make?.value })} + /> +
+
+
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ +
@@ -49,7 +406,7 @@ class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white" > Image @@ -75,7 +432,7 @@ class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white" >
-
- - -
-
-
-

PEOPLE

-
- -
- -
-
-
- -
- -
-

PLACE

- -
-
-

Country

- -
- -
-

State

- -
- -
-

City

- -
-
-
- -
- -
-

CAMERA

- -
-
-

Make

- -
- -
-

Model

- -
-
-
- -
- - -
-
- - -
- -
- - -
-
- -
- - +
+ +