mirror of
https://github.com/immich-app/immich.git
synced 2025-03-11 02:23:09 -05:00
feat: search by description (#15818)
* feat: search by description * wip: mobile * wip: mobile ui * wip: mobile search logic * feat: using f_unaccent * icon to fit with text search
This commit is contained in:
parent
a808a840c8
commit
4efacfbb91
16 changed files with 217 additions and 31 deletions
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -2,3 +2,9 @@ enum SortOrder {
|
|||
asc,
|
||||
desc,
|
||||
}
|
||||
|
||||
enum TextSearchType {
|
||||
context,
|
||||
filename,
|
||||
description,
|
||||
}
|
||||
|
|
|
@ -235,6 +235,7 @@ class SearchDisplayFilters {
|
|||
class SearchFilter {
|
||||
String? context;
|
||||
String? filename;
|
||||
String? description;
|
||||
Set<Person> 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<Person>? 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 ^
|
||||
|
|
|
@ -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>(TextSearchType.context);
|
||||
final searchHintText = useState<String>('contextual_search'.tr());
|
||||
final textSearchController = useTextEditingController();
|
||||
final filter = useState<SearchFilter>(
|
||||
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,
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
19
mobile/openapi/lib/model/metadata_search_dto.dart
generated
19
mobile/openapi/lib/model/metadata_search_dto.dart
generated
|
@ -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<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
@ -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<String>(json, r'country'),
|
||||
createdAfter: mapDateTime(json, r'createdAfter', r''),
|
||||
createdBefore: mapDateTime(json, r'createdBefore', r''),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId'),
|
||||
deviceId: mapValueOfType<String>(json, r'deviceId'),
|
||||
encodedVideoPath: mapValueOfType<String>(json, r'encodedVideoPath'),
|
||||
|
|
|
@ -9949,6 +9949,9 @@
|
|||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceAssetId": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
@ -769,6 +769,7 @@ export type MetadataSearchDto = {
|
|||
country?: string | null;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
description?: string;
|
||||
deviceAssetId?: string;
|
||||
deviceId?: string;
|
||||
encodedVideoPath?: string;
|
||||
|
|
|
@ -133,6 +133,11 @@ export class MetadataSearchDto extends RandomSearchDto {
|
|||
@Optional()
|
||||
deviceAssetId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
|
|
|
@ -396,6 +396,11 @@ export function searchAssetBuilder(kysely: Kysely<DB>, 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!))
|
||||
|
|
|
@ -101,6 +101,7 @@ export interface SearchExifOptions {
|
|||
make?: string | null;
|
||||
model?: string | null;
|
||||
state?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface SearchEmbeddingOptions {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
export type SearchFilter = {
|
||||
query: string;
|
||||
queryType: 'smart' | 'metadata';
|
||||
queryType: 'smart' | 'metadata' | 'description';
|
||||
personIds: SvelteSet<string>;
|
||||
tagIds: SvelteSet<string>;
|
||||
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,
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
<RadioButton
|
||||
name="query-type"
|
||||
id="description-radio"
|
||||
label={$t('description')}
|
||||
bind:group={queryType}
|
||||
value="description"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
|
@ -34,7 +41,7 @@
|
|||
placeholder={$t('sunrise_on_the_beach')}
|
||||
bind:value={query}
|
||||
/>
|
||||
{:else}
|
||||
{:else if queryType === 'metadata'}
|
||||
<label for="file-name-input" class="immich-form-label">{$t('search_by_filename')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full !mt-1"
|
||||
|
@ -45,4 +52,15 @@
|
|||
bind:value={query}
|
||||
aria-labelledby="file-name-label"
|
||||
/>
|
||||
{:else if queryType === 'description'}
|
||||
<label for="description-input" class="immich-form-label">{$t('search_by_description')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full !mt-1"
|
||||
type="text"
|
||||
id="description-input"
|
||||
name="description"
|
||||
placeholder={$t('search_by_description_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="description-label"
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
@ -197,6 +197,7 @@
|
|||
personIds: $t('people'),
|
||||
tagIds: $t('tags'),
|
||||
originalFileName: $t('file_name'),
|
||||
description: $t('description'),
|
||||
};
|
||||
return keyMap[key] || key;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue