diff --git a/i18n/en.json b/i18n/en.json index ad48a96991..217c62b76f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,4 +1,6 @@ { + "search_by_description_example": "Hiking day in Sapa", + "search_by_description": "Search by description", "about": "About", "account": "Account", "account_settings": "Account Settings", @@ -1350,4 +1352,4 @@ "yes": "Yes", "you_dont_have_any_shared_links": "You don't have any shared links", "zoom_image": "Zoom Image" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 410c88e57b..32061d720f 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -1,5 +1,9 @@ { + "search_filter_contextual": "Search by context", + "search_filter_filename": "Search by file name", + "search_filter_description": "Search by description", "search_no_result": "No results found, try a different search term or combination", + "description_search": "Hiking day in Sapa", "search_no_more_result": "No more results", "action_common_back": "Back", "action_common_cancel": "Cancel", diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index a9b5107426..3a3bf9959a 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -2,3 +2,9 @@ enum SortOrder { asc, desc, } + +enum TextSearchType { + context, + filename, + description, +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 0df64b6924..87e7b24e34 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -235,6 +235,7 @@ class SearchDisplayFilters { class SearchFilter { String? context; String? filename; + String? description; Set people; SearchLocationFilter location; SearchCameraFilter camera; @@ -247,6 +248,7 @@ class SearchFilter { SearchFilter({ this.context, this.filename, + this.description, required this.people, required this.location, required this.camera, @@ -258,6 +260,7 @@ class SearchFilter { bool get isEmpty { return (context == null || (context != null && context!.isEmpty)) && (filename == null || (filename!.isEmpty)) && + (description == null || (description!.isEmpty)) && people.isEmpty && location.country == null && location.state == null && @@ -275,6 +278,7 @@ class SearchFilter { SearchFilter copyWith({ String? context, String? filename, + String? description, Set? people, SearchLocationFilter? location, SearchCameraFilter? camera, @@ -285,6 +289,7 @@ class SearchFilter { return SearchFilter( context: context ?? this.context, filename: filename ?? this.filename, + description: description ?? this.description, people: people ?? this.people, location: location ?? this.location, camera: camera ?? this.camera, @@ -296,7 +301,7 @@ class SearchFilter { @override String toString() { - return 'SearchFilter(context: $context, filename: $filename, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)'; + return 'SearchFilter(context: $context, filename: $filename, description: $description, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)'; } @override @@ -305,6 +310,7 @@ class SearchFilter { return other.context == context && other.filename == filename && + other.description == description && other.people == people && other.location == location && other.camera == camera && @@ -317,6 +323,7 @@ class SearchFilter { int get hashCode { return context.hashCode ^ filename.hashCode ^ + description.hashCode ^ people.hashCode ^ location.hashCode ^ camera.hashCode ^ diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 385da9dbcb..fcae1fb586 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; @@ -31,7 +32,8 @@ class SearchPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isContextualSearch = useState(true); + final textSearchType = useState(TextSearchType.context); + final searchHintText = useState('contextual_search'.tr()); final textSearchController = useTextEditingController(); final filter = useState( SearchFilter( @@ -478,37 +480,148 @@ class SearchPage extends HookConsumerWidget { } handleTextSubmitted(String value) { - if (isContextualSearch.value) { - filter.value = filter.value.copyWith( - filename: '', - context: value, - ); - } else { - filter.value = filter.value.copyWith( - filename: value, - context: '', - ); + switch (textSearchType.value) { + case TextSearchType.context: + filter.value = filter.value.copyWith( + filename: '', + context: value, + description: '', + ); + + break; + case TextSearchType.filename: + filter.value = filter.value.copyWith( + filename: value, + context: '', + description: '', + ); + + break; + case TextSearchType.description: + filter.value = filter.value.copyWith( + filename: '', + context: '', + description: value, + ); + break; } search(); } + IconData getSearchPrefixIcon() { + switch (textSearchType.value) { + case TextSearchType.context: + return Icons.image_search_rounded; + case TextSearchType.filename: + return Icons.abc_rounded; + case TextSearchType.description: + return Icons.text_snippet_outlined; + default: + return Icons.search_rounded; + } + } + return Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( automaticallyImplyLeading: true, actions: [ Padding( - padding: const EdgeInsets.only(right: 14.0), - child: IconButton( - key: const Key('contextual_search_button'), - icon: isContextualSearch.value - ? const Icon(Icons.abc_rounded) - : const Icon(Icons.image_search_rounded), - onPressed: () { - isContextualSearch.value = !isContextualSearch.value; - textSearchController.clear(); + padding: const EdgeInsets.only(right: 16.0), + child: MenuAnchor( + style: MenuStyle( + elevation: const WidgetStatePropertyAll(1), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.all(4), + ), + ), + builder: ( + BuildContext context, + MenuController controller, + Widget? child, + ) { + return IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.more_vert_rounded), + tooltip: 'Show text search menu', + ); }, + menuChildren: [ + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.image_search_rounded), + title: Text( + 'search_filter_contextual'.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.context + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.context, + ), + onPressed: () { + textSearchType.value = TextSearchType.context; + searchHintText.value = 'contextual_search'.tr(); + }, + ), + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.abc_rounded), + title: Text( + 'search_filter_filename'.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.filename + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.filename, + ), + onPressed: () { + textSearchType.value = TextSearchType.filename; + searchHintText.value = 'filename_search'.tr(); + }, + ), + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.text_snippet_outlined), + title: Text( + 'search_filter_description'.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: + textSearchType.value == TextSearchType.description + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: + textSearchType.value == TextSearchType.description, + ), + onPressed: () { + textSearchType.value = TextSearchType.description; + searchHintText.value = 'description_search'.tr(); + }, + ), + ], ), ), ], @@ -539,12 +652,10 @@ class SearchPage extends HookConsumerWidget { prefixIcon: prefilter != null ? null : Icon( - Icons.search_rounded, + getSearchPrefixIcon(), color: context.colorScheme.primary, ), - hintText: isContextualSearch.value - ? 'contextual_search'.tr() - : 'filename_search'.tr(), + hintText: searchHintText.value, hintStyle: context.textTheme.bodyLarge?.copyWith( color: context.themeData.colorScheme.onSurfaceSecondary, ), diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index fe8f7393c2..4c6c80abf3 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -84,6 +84,10 @@ class SearchService { ? filter.filename : null, country: filter.location.country, + description: + filter.description != null && filter.description!.isNotEmpty + ? filter.description + : null, state: filter.location.state, city: filter.location.city, make: filter.camera.make, diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 30b6a74bb1..86ef9a8bb0 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -168,7 +168,7 @@ class LoginForm extends HookConsumerWidget { populateTestLoginInfo1() { emailController.text = 'testuser@email.com'; passwordController.text = 'password'; - serverEndpointController.text = 'http://10.1.15.216:3000/api'; + serverEndpointController.text = 'http://10.1.15.216:2283/api'; } login() async { diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 5f9e3f8e15..3a3c141442 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -18,6 +18,7 @@ class MetadataSearchDto { this.country, this.createdAfter, this.createdBefore, + this.description, this.deviceAssetId, this.deviceId, this.encodedVideoPath, @@ -85,6 +86,14 @@ class MetadataSearchDto { /// DateTime? createdBefore; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? description; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -343,6 +352,7 @@ class MetadataSearchDto { other.country == country && other.createdAfter == createdAfter && other.createdBefore == createdBefore && + other.description == description && other.deviceAssetId == deviceAssetId && other.deviceId == deviceId && other.encodedVideoPath == encodedVideoPath && @@ -389,6 +399,7 @@ class MetadataSearchDto { (country == null ? 0 : country!.hashCode) + (createdAfter == null ? 0 : createdAfter!.hashCode) + (createdBefore == null ? 0 : createdBefore!.hashCode) + + (description == null ? 0 : description!.hashCode) + (deviceAssetId == null ? 0 : deviceAssetId!.hashCode) + (deviceId == null ? 0 : deviceId!.hashCode) + (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) + @@ -428,7 +439,7 @@ class MetadataSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -457,6 +468,11 @@ class MetadataSearchDto { } else { // json[r'createdBefore'] = null; } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } if (this.deviceAssetId != null) { json[r'deviceAssetId'] = this.deviceAssetId; } else { @@ -643,6 +659,7 @@ class MetadataSearchDto { country: mapValueOfType(json, r'country'), createdAfter: mapDateTime(json, r'createdAfter', r''), createdBefore: mapDateTime(json, r'createdBefore', r''), + description: mapValueOfType(json, r'description'), deviceAssetId: mapValueOfType(json, r'deviceAssetId'), deviceId: mapValueOfType(json, r'deviceId'), encodedVideoPath: mapValueOfType(json, r'encodedVideoPath'), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 090a3267d4..85dc55aed8 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9949,6 +9949,9 @@ "format": "date-time", "type": "string" }, + "description": { + "type": "string" + }, "deviceAssetId": { "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index bbd41c3ecb..32cca2dbce 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -769,6 +769,7 @@ export type MetadataSearchDto = { country?: string | null; createdAfter?: string; createdBefore?: string; + description?: string; deviceAssetId?: string; deviceId?: string; encodedVideoPath?: string; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 9dabfff25f..6cf34debef 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -133,6 +133,11 @@ export class MetadataSearchDto extends RandomSearchDto { @Optional() deviceAssetId?: string; + @IsString() + @IsNotEmpty() + @Optional() + description?: string; + @IsString() @IsNotEmpty() @Optional() diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 605fbb0456..594dd17785 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -396,6 +396,11 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild sql`'%' || f_unaccent(${options.originalFileName}) || '%'`, ), ) + .$if(!!options.description, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where(sql`f_unaccent(exif.description)`, 'ilike', sql`'%' || f_unaccent(${options.description}) || '%'`), + ) .$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) .$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!)) diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index e6f9acbd21..b9ae7b7194 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -101,6 +101,7 @@ export interface SearchExifOptions { make?: string | null; model?: string | null; state?: string | null; + description?: string | null; } export interface SearchEmbeddingOptions { diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte index 7653ad3413..8170010332 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte @@ -6,7 +6,7 @@ export type SearchFilter = { query: string; - queryType: 'smart' | 'metadata'; + queryType: 'smart' | 'metadata' | 'description'; personIds: SvelteSet; tagIds: SvelteSet; location: SearchLocationFilter; @@ -110,6 +110,7 @@ let payload: SmartSearchDto | MetadataSearchDto = { query: filter.queryType === 'smart' ? query : undefined, originalFileName: filter.queryType === 'metadata' ? query : undefined, + description: filter.queryType === 'description' ? query : undefined, country: filter.location.country, state: filter.location.state, city: filter.location.city, diff --git a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte index 2f118e6567..085e43b065 100644 --- a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte @@ -4,7 +4,7 @@ interface Props { query: string | undefined; - queryType?: 'smart' | 'metadata'; + queryType?: 'smart' | 'metadata' | 'description'; } let { query = $bindable(), queryType = $bindable('smart') }: Props = $props(); @@ -21,6 +21,13 @@ bind:group={queryType} value="metadata" /> + @@ -34,7 +41,7 @@ placeholder={$t('sunrise_on_the_beach')} bind:value={query} /> -{:else} +{:else if queryType === 'metadata'} +{:else if queryType === 'description'} + + {/if} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index c416226c41..5bb1ecce03 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -197,6 +197,7 @@ personIds: $t('people'), tagIds: $t('tags'), originalFileName: $t('file_name'), + description: $t('description'), }; return keyMap[key] || key; }