From 5990a2887021c0be65dd00eac88d020c95128c9c Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 2 Mar 2022 16:44:24 -0600 Subject: [PATCH] Implemented search result page (#37) --- .../modules/home/ui/immich_sliver_appbar.dart | 8 +- .../search_result_page_state.provider.dart | 111 ++++++++++ .../search/services/search.service.dart | 19 ++ .../search/services/store_services_here.txt | 0 mobile/lib/modules/search/ui/search_bar.dart | 15 +- .../search/ui/search_suggestion_list.dart | 5 +- .../lib/modules/search/views/search_page.dart | 17 +- .../search/views/search_result_page.dart | 197 ++++++++++++++++++ mobile/lib/routing/auth_guard.dart | 1 - mobile/lib/routing/router.dart | 2 + mobile/lib/routing/router.gr.dart | 34 ++- server/src/api-v1/asset/asset.controller.ts | 6 + server/src/api-v1/asset/asset.service.ts | 21 ++ .../src/api-v1/asset/dto/search-asset.dto.ts | 6 + .../1646249209023-AddExifTextSearchColumn.ts | 25 +++ ...1646249734844-CreateExifTextSearchIndex.ts | 17 ++ 16 files changed, 467 insertions(+), 17 deletions(-) create mode 100644 mobile/lib/modules/search/providers/search_result_page_state.provider.dart delete mode 100644 mobile/lib/modules/search/services/store_services_here.txt create mode 100644 mobile/lib/modules/search/views/search_result_page.dart create mode 100644 server/src/api-v1/asset/dto/search-asset.dto.ts create mode 100644 server/src/migration/1646249209023-AddExifTextSearchColumn.ts create mode 100644 server/src/migration/1646249734844-CreateExifTextSearchIndex.ts diff --git a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart index ecd46fdc4c743..59e898f358074 100644 --- a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart +++ b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart @@ -83,12 +83,8 @@ class ImmichSliverAppBar extends ConsumerWidget { ), child: const Icon(Icons.backup_rounded)), tooltip: 'Backup Controller', - onPressed: () async { - var onPop = await AutoRouter.of(context).push(const BackupControllerRoute()); - - if (onPop == true) { - onPopBack!(); - } + onPressed: () { + AutoRouter.of(context).push(const BackupControllerRoute()); }, ), _backupState.backupProgress == BackUpProgressEnum.inProgress diff --git a/mobile/lib/modules/search/providers/search_result_page_state.provider.dart b/mobile/lib/modules/search/providers/search_result_page_state.provider.dart new file mode 100644 index 0000000000000..3c3960e04c5bb --- /dev/null +++ b/mobile/lib/modules/search/providers/search_result_page_state.provider.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:immich_mobile/modules/search/services/search.service.dart'; +import 'package:immich_mobile/shared/models/immich_asset.model.dart'; +import 'package:intl/intl.dart'; + +class SearchresultPageState { + final bool isLoading; + final bool isSuccess; + final bool isError; + final List searchResult; + + SearchresultPageState({ + required this.isLoading, + required this.isSuccess, + required this.isError, + required this.searchResult, + }); + + SearchresultPageState copyWith({ + bool? isLoading, + bool? isSuccess, + bool? isError, + List? searchResult, + }) { + return SearchresultPageState( + isLoading: isLoading ?? this.isLoading, + isSuccess: isSuccess ?? this.isSuccess, + isError: isError ?? this.isError, + searchResult: searchResult ?? this.searchResult, + ); + } + + Map toMap() { + return { + 'isLoading': isLoading, + 'isSuccess': isSuccess, + 'isError': isError, + 'searchResult': searchResult.map((x) => x.toMap()).toList(), + }; + } + + factory SearchresultPageState.fromMap(Map map) { + return SearchresultPageState( + isLoading: map['isLoading'] ?? false, + isSuccess: map['isSuccess'] ?? false, + isError: map['isError'] ?? false, + searchResult: List.from(map['searchResult']?.map((x) => ImmichAsset.fromMap(x))), + ); + } + + String toJson() => json.encode(toMap()); + + factory SearchresultPageState.fromJson(String source) => SearchresultPageState.fromMap(json.decode(source)); + + @override + String toString() { + return 'SearchresultPageState(isLoading: $isLoading, isSuccess: $isSuccess, isError: $isError, searchResult: $searchResult)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + + return other is SearchresultPageState && + other.isLoading == isLoading && + other.isSuccess == isSuccess && + other.isError == isError && + listEquals(other.searchResult, searchResult); + } + + @override + int get hashCode { + return isLoading.hashCode ^ isSuccess.hashCode ^ isError.hashCode ^ searchResult.hashCode; + } +} + +class SearchResultPageStateNotifier extends StateNotifier { + SearchResultPageStateNotifier() + : super(SearchresultPageState(searchResult: [], isError: false, isLoading: true, isSuccess: false)); + + final SearchService _searchService = SearchService(); + + search(String searchTerm) async { + state = state.copyWith(searchResult: [], isError: false, isLoading: true, isSuccess: false); + + List? assets = await _searchService.searchAsset(searchTerm); + + if (assets != null) { + state = state.copyWith(searchResult: assets, isError: false, isLoading: false, isSuccess: true); + } else { + state = state.copyWith(searchResult: [], isError: true, isLoading: false, isSuccess: false); + } + } +} + +final searchResultPageStateProvider = + StateNotifierProvider((ref) { + return SearchResultPageStateNotifier(); +}); + +final searchResultGroupByDateTimeProvider = StateProvider((ref) { + var assets = ref.watch(searchResultPageStateProvider).searchResult; + + assets.sortByCompare((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a)); + return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt))); +}); diff --git a/mobile/lib/modules/search/services/search.service.dart b/mobile/lib/modules/search/services/search.service.dart index d7c9101ca24a5..b54df26291325 100644 --- a/mobile/lib/modules/search/services/search.service.dart +++ b/mobile/lib/modules/search/services/search.service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/services/network.service.dart'; class SearchService { @@ -17,4 +18,22 @@ class SearchService { return []; } } + + Future?> searchAsset(String searchTerm) async { + try { + var res = await _networkService.postRequest( + url: "asset/search", + data: {"searchTerm": searchTerm}, + ); + + List decodedData = jsonDecode(res.toString()); + + List result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a))); + + return result; + } catch (e) { + debugPrint("[ERROR] [searchAsset] ${e.toString()}"); + return null; + } + } } diff --git a/mobile/lib/modules/search/services/store_services_here.txt b/mobile/lib/modules/search/services/store_services_here.txt deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/mobile/lib/modules/search/ui/search_bar.dart b/mobile/lib/modules/search/ui/search_bar.dart index a8dbebd31ae46..b3af1af4e22bf 100644 --- a/mobile/lib/modules/search/ui/search_bar.dart +++ b/mobile/lib/modules/search/ui/search_bar.dart @@ -4,8 +4,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; class SearchBar extends HookConsumerWidget with PreferredSizeWidget { - SearchBar({Key? key, required this.searchFocusNode}) : super(key: key); - FocusNode searchFocusNode; + SearchBar({Key? key, required this.searchFocusNode, required this.onSubmitted}) : super(key: key); + + final FocusNode searchFocusNode; + final Function(String) onSubmitted; @override Widget build(BuildContext context, WidgetRef ref) { @@ -19,6 +21,7 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget { onPressed: () { searchFocusNode.unfocus(); ref.watch(searchPageStateProvider.notifier).disableSearch(); + searchTermController.clear(); }, icon: const Icon(Icons.arrow_back_ios_rounded)) : const Icon(Icons.search_rounded), @@ -27,13 +30,17 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget { focusNode: searchFocusNode, autofocus: false, onTap: () { + searchTermController.clear(); ref.watch(searchPageStateProvider.notifier).getSuggestedSearchTerms(); ref.watch(searchPageStateProvider.notifier).enableSearch(); + ref.watch(searchPageStateProvider.notifier).setSearchTerm(""); + searchFocusNode.requestFocus(); }, onSubmitted: (searchTerm) { - ref.watch(searchPageStateProvider.notifier).disableSearch(); - searchFocusNode.unfocus(); + onSubmitted(searchTerm); + searchTermController.clear(); + ref.watch(searchPageStateProvider.notifier).setSearchTerm(""); }, onChanged: (value) { ref.watch(searchPageStateProvider.notifier).setSearchTerm(value); diff --git a/mobile/lib/modules/search/ui/search_suggestion_list.dart b/mobile/lib/modules/search/ui/search_suggestion_list.dart index b3a73b5fc9939..7392cf6e81f90 100644 --- a/mobile/lib/modules/search/ui/search_suggestion_list.dart +++ b/mobile/lib/modules/search/ui/search_suggestion_list.dart @@ -3,8 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; class SearchSuggestionList extends ConsumerWidget { - const SearchSuggestionList({Key? key}) : super(key: key); + const SearchSuggestionList({Key? key, required this.onSubmitted}) : super(key: key); + final Function(String) onSubmitted; @override Widget build(BuildContext context, WidgetRef ref) { final searchTerm = ref.watch(searchPageStateProvider).searchTerm; @@ -20,7 +21,7 @@ class SearchSuggestionList extends ConsumerWidget { itemBuilder: ((context, index) { return ListTile( onTap: () { - print("navigate to this search result: ${searchSuggestion[index]} "); + onSubmitted(searchSuggestion[index]); }, title: Text(searchSuggestion[index]), ); diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index 373fd70b1240d..b939966e8bfc1 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/mobile/lib/modules/search/views/search_page.dart @@ -1,9 +1,11 @@ +import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; import 'package:immich_mobile/modules/search/ui/search_bar.dart'; import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart'; +import 'package:immich_mobile/routing/router.dart'; // ignore: must_be_immutable class SearchPage extends HookConsumerWidget { @@ -16,13 +18,22 @@ class SearchPage extends HookConsumerWidget { final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled; useEffect(() { - print("search"); searchFocusNode = FocusNode(); return () => searchFocusNode.dispose(); }, []); + _onSearchSubmitted(String searchTerm) async { + searchFocusNode.unfocus(); + ref.watch(searchPageStateProvider.notifier).disableSearch(); + + AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm)); + } + return Scaffold( - appBar: SearchBar(searchFocusNode: searchFocusNode), + appBar: SearchBar( + searchFocusNode: searchFocusNode, + onSubmitted: _onSearchSubmitted, + ), body: GestureDetector( onTap: () { searchFocusNode.unfocus(); @@ -58,7 +69,7 @@ class SearchPage extends HookConsumerWidget { ), ], ), - isSearchEnabled ? const SearchSuggestionList() : Container(), + isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(), ], ), ), diff --git a/mobile/lib/modules/search/views/search_result_page.dart b/mobile/lib/modules/search/views/search_result_page.dart new file mode 100644 index 0000000000000..749e5d04cd595 --- /dev/null +++ b/mobile/lib/modules/search/views/search_result_page.dart @@ -0,0 +1,197 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/ui/daily_title_text.dart'; +import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; +import 'package:immich_mobile/modules/home/ui/image_grid.dart'; +import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart'; +import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; +import 'package:immich_mobile/modules/search/providers/search_result_page_state.provider.dart'; +import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart'; + +class SearchResultPage extends HookConsumerWidget { + SearchResultPage({Key? key, required this.searchTerm}) : super(key: key); + + final String searchTerm; + late FocusNode searchFocusNode; + + @override + Widget build(BuildContext context, WidgetRef ref) { + ScrollController _scrollController = useScrollController(); + final searchTermController = useTextEditingController(text: ""); + final isNewSearch = useState(false); + final currentSearchTerm = useState(searchTerm); + + List _imageGridGroup = []; + + useEffect(() { + searchFocusNode = FocusNode(); + + Future.delayed(Duration.zero, () => ref.read(searchResultPageStateProvider.notifier).search(searchTerm)); + return () => searchFocusNode.dispose(); + }, []); + + _onSearchSubmitted(String newSearchTerm) { + debugPrint("Re-Search with $newSearchTerm"); + searchFocusNode.unfocus(); + isNewSearch.value = false; + currentSearchTerm.value = newSearchTerm; + ref.watch(searchResultPageStateProvider.notifier).search(newSearchTerm); + } + + _buildTextField() { + return TextField( + controller: searchTermController, + focusNode: searchFocusNode, + autofocus: false, + onTap: () { + searchTermController.clear(); + ref.watch(searchPageStateProvider.notifier).setSearchTerm(""); + searchFocusNode.requestFocus(); + }, + textInputAction: TextInputAction.search, + onSubmitted: (searchTerm) { + if (searchTerm.isNotEmpty) { + searchTermController.clear(); + _onSearchSubmitted(searchTerm); + } else { + isNewSearch.value = false; + } + }, + onChanged: (value) { + ref.watch(searchPageStateProvider.notifier).setSearchTerm(value); + }, + decoration: const InputDecoration( + hintText: 'New Search', + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), + ), + ), + ); + } + + _buildChip() { + return Chip( + label: Wrap( + spacing: 5, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Text( + currentSearchTerm.value, + style: TextStyle(color: Theme.of(context).primaryColor), + maxLines: 1, + ), + ), + Icon( + Icons.close_rounded, + color: Theme.of(context).primaryColor, + size: 20, + ), + ], + ), + backgroundColor: Theme.of(context).primaryColor.withAlpha(50), + ); + } + + _buildSearchResult() { + var searchResultPageState = ref.watch(searchResultPageStateProvider); + var assetGroupByDateTime = ref.watch(searchResultGroupByDateTimeProvider); + + if (searchResultPageState.isError) { + return const Text("Error"); + } + + if (searchResultPageState.isLoading) { + return const CircularProgressIndicator.adaptive(); + } + + if (searchResultPageState.isSuccess) { + if (searchResultPageState.searchResult.isNotEmpty) { + int? lastMonth; + + assetGroupByDateTime.forEach((dateGroup, immichAssetList) { + DateTime parseDateGroup = DateTime.parse(dateGroup); + int currentMonth = parseDateGroup.month; + + if (lastMonth != null) { + if (currentMonth - lastMonth! != 0) { + _imageGridGroup.add( + MonthlyTitleText( + isoDate: dateGroup, + ), + ); + } + } + + _imageGridGroup.add( + DailyTitleText( + isoDate: dateGroup, + assetGroup: immichAssetList, + ), + ); + + _imageGridGroup.add( + ImageGrid(assetGroup: immichAssetList), + ); + + lastMonth = currentMonth; + }); + + return DraggableScrollbar.semicircle( + backgroundColor: Theme.of(context).primaryColor, + controller: _scrollController, + heightScrollThumb: 48.0, + child: CustomScrollView( + controller: _scrollController, + slivers: [..._imageGridGroup], + ), + ); + } else { + return const Text("No assets found"); + } + } + + return Container(); + } + + return Scaffold( + appBar: AppBar( + leading: IconButton( + splashRadius: 20, + onPressed: () { + if (isNewSearch.value) { + isNewSearch.value = false; + } else { + AutoRouter.of(context).pop(true); + } + }, + icon: const Icon(Icons.arrow_back_ios_rounded), + ), + title: GestureDetector( + onTap: () { + isNewSearch.value = true; + searchFocusNode.requestFocus(); + }, + child: isNewSearch.value ? _buildTextField() : _buildChip()), + centerTitle: false, + ), + body: GestureDetector( + onTap: () { + searchFocusNode.unfocus(); + ref.watch(searchPageStateProvider.notifier).disableSearch(); + }, + child: Stack( + children: [ + _buildSearchResult(), + isNewSearch.value ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart index c0aec9a9967fd..3677a4f631b7b 100644 --- a/mobile/lib/routing/auth_guard.dart +++ b/mobile/lib/routing/auth_guard.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:auto_route/auto_route.dart'; -import 'package:flutter/cupertino.dart'; import 'package:immich_mobile/shared/services/network.service.dart'; class AuthGuard extends AutoRouteGuard { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index ccea91bd1ffd6..92dc4ae8e32ff 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart'; import 'package:immich_mobile/modules/home/views/home_page.dart'; import 'package:immich_mobile/modules/search/views/search_page.dart'; +import 'package:immich_mobile/modules/search/views/search_result_page.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/views/backup_controller_page.dart'; @@ -27,6 +28,7 @@ part 'router.gr.dart'; AutoRoute(page: ImageViewerPage, guards: [AuthGuard]), AutoRoute(page: VideoViewerPage, guards: [AuthGuard]), AutoRoute(page: BackupControllerPage, guards: [AuthGuard]), + AutoRoute(page: SearchResultPage, guards: [AuthGuard]), ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index e6fe3d38282ad..040cd58299d1e 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -50,6 +50,12 @@ class _$AppRouter extends RootStackRouter { return MaterialPageX( routeData: routeData, child: const BackupControllerPage()); }, + SearchResultRoute.name: (routeData) { + final args = routeData.argsAs(); + return MaterialPageX( + routeData: routeData, + child: SearchResultPage(key: args.key, searchTerm: args.searchTerm)); + }, HomeRoute.name: (routeData) { return MaterialPageX( routeData: routeData, child: const HomePage()); @@ -85,7 +91,9 @@ class _$AppRouter extends RootStackRouter { RouteConfig(VideoViewerRoute.name, path: '/video-viewer-page', guards: [authGuard]), RouteConfig(BackupControllerRoute.name, - path: '/backup-controller-page', guards: [authGuard]) + path: '/backup-controller-page', guards: [authGuard]), + RouteConfig(SearchResultRoute.name, + path: '/search-result-page', guards: [authGuard]) ]; } @@ -185,6 +193,30 @@ class BackupControllerRoute extends PageRouteInfo { static const String name = 'BackupControllerRoute'; } +/// generated route for +/// [SearchResultPage] +class SearchResultRoute extends PageRouteInfo { + SearchResultRoute({Key? key, required String searchTerm}) + : super(SearchResultRoute.name, + path: '/search-result-page', + args: SearchResultRouteArgs(key: key, searchTerm: searchTerm)); + + static const String name = 'SearchResultRoute'; +} + +class SearchResultRouteArgs { + const SearchResultRouteArgs({this.key, required this.searchTerm}); + + final Key? key; + + final String searchTerm; + + @override + String toString() { + return 'SearchResultRouteArgs{key: $key, searchTerm: $searchTerm}'; + } +} + /// generated route for /// [HomePage] class HomeRoute extends PageRouteInfo { diff --git a/server/src/api-v1/asset/asset.controller.ts b/server/src/api-v1/asset/asset.controller.ts index 3f77e5688d0c8..8aec5ead2b638 100644 --- a/server/src/api-v1/asset/asset.controller.ts +++ b/server/src/api-v1/asset/asset.controller.ts @@ -28,6 +28,7 @@ import { Response as Res } from 'express'; import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto'; import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; import { DeleteAssetDto } from './dto/delete-asset.dto'; +import { SearchAssetDto } from './dto/search-asset.dto'; @UseGuards(JwtAuthGuard) @Controller('asset') @@ -76,6 +77,11 @@ export class AssetController { return this.assetService.getAssetSearchTerm(authUser); } + @Post('/search') + async searchAsset(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) searchAssetDto: SearchAssetDto) { + return this.assetService.searchAsset(authUser, searchAssetDto); + } + @Get('/new') async getNewAssets(@GetAuthUser() authUser: AuthUserDto, @Query(ValidationPipe) query: GetNewAssetQueryDto) { return await this.assetService.getNewAssets(authUser, query.latestDate); diff --git a/server/src/api-v1/asset/asset.service.ts b/server/src/api-v1/asset/asset.service.ts index 503c9ddbe3257..4f179f9993d25 100644 --- a/server/src/api-v1/asset/asset.service.ts +++ b/server/src/api-v1/asset/asset.service.ts @@ -13,6 +13,7 @@ import { ServeFileDto } from './dto/serve-file.dto'; import { Response as Res } from 'express'; import { promisify } from 'util'; import { DeleteAssetDto } from './dto/delete-asset.dto'; +import { SearchAssetDto } from './dto/search-asset.dto'; const fileInfo = promisify(stat); @@ -277,4 +278,24 @@ export class AssetService { return Array.from(possibleSearchTerm).filter((x) => x != null); } + + async searchAsset(authUser: AuthUserDto, searchAssetDto: SearchAssetDto) { + const query = ` + SELECT a.* + FROM assets a + LEFT JOIN smart_info si ON a.id = si."assetId" + LEFT JOIN exif e ON a.id = e."assetId" + + WHERE a."userId" = $1 + AND + ( + TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR + e.exif_text_searchable_column @@ PLAINTO_TSQUERY('english', $2) + ); + `; + + const rows = await this.assetRepository.query(query, [authUser.id, searchAssetDto.searchTerm]); + + return rows; + } } diff --git a/server/src/api-v1/asset/dto/search-asset.dto.ts b/server/src/api-v1/asset/dto/search-asset.dto.ts new file mode 100644 index 0000000000000..aeca2c04436c7 --- /dev/null +++ b/server/src/api-v1/asset/dto/search-asset.dto.ts @@ -0,0 +1,6 @@ +import { IsNotEmpty } from 'class-validator'; + +export class SearchAssetDto { + @IsNotEmpty() + searchTerm: string; +} diff --git a/server/src/migration/1646249209023-AddExifTextSearchColumn.ts b/server/src/migration/1646249209023-AddExifTextSearchColumn.ts new file mode 100644 index 0000000000000..071d4bd40d2b1 --- /dev/null +++ b/server/src/migration/1646249209023-AddExifTextSearchColumn.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddExifTextSearchColumn1646249209023 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE exif + ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector + GENERATED ALWAYS AS ( + TO_TSVECTOR('english', + COALESCE(make, '') || ' ' || + COALESCE(model, '') || ' ' || + COALESCE(orientation, '') || ' ' || + COALESCE("lensModel", '') + ) + ) STORED; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE exif + DROP COLUMN IF EXISTS exif_text_searchable_column; + `); + } +} diff --git a/server/src/migration/1646249734844-CreateExifTextSearchIndex.ts b/server/src/migration/1646249734844-CreateExifTextSearchIndex.ts new file mode 100644 index 0000000000000..664d06c4bc55f --- /dev/null +++ b/server/src/migration/1646249734844-CreateExifTextSearchIndex.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateExifTextSearchIndex1646249734844 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX exif_text_searchable_idx + ON exif + USING GIN (exif_text_searchable_column); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS exif_text_searchable_idx ON exif; + `); + } +}