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 1657aa69c..12d20ea13 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index 40b44fb01..8c924428f 100644 Binary files a/mobile/openapi/doc/SearchApi.md and b/mobile/openapi/doc/SearchApi.md differ diff --git a/mobile/openapi/doc/SearchSuggestionType.md b/mobile/openapi/doc/SearchSuggestionType.md new file mode 100644 index 000000000..e37b3f0de Binary files /dev/null and b/mobile/openapi/doc/SearchSuggestionType.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 48305a980..f2903e351 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 55d737a8d..16d2b3003 100644 Binary files a/mobile/openapi/lib/api/search_api.dart and b/mobile/openapi/lib/api/search_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9643a9042..a8cf4c34c 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index c01b24703..f37ba588a 100644 Binary files a/mobile/openapi/lib/api_helper.dart and b/mobile/openapi/lib/api_helper.dart differ 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 000000000..d33b4a69d Binary files /dev/null and b/mobile/openapi/lib/model/search_suggestion_type.dart differ diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index be12c7e1f..d89c47e74 100644 Binary files a/mobile/openapi/test/search_api_test.dart and b/mobile/openapi/test/search_api_test.dart differ 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 000000000..f86a7de90 Binary files /dev/null and b/mobile/openapi/test/search_suggestion_type_test.dart differ 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 bb44cbeeb..00b8c6832 100644 Binary files a/open-api/typescript-sdk/fetch-client.ts and b/open-api/typescript-sdk/fetch-client.ts differ 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

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