import "dart:math"; import "package:flutter/cupertino.dart"; import "package:intl/intl.dart"; import 'package:logging/logging.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/data/holidays.dart'; import 'package:photos/data/months.dart'; import 'package:photos/data/years.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/events/local_photos_updated_event.dart'; import "package:photos/extensions/string_ext.dart"; import "package:photos/models/api/collection/user.dart"; import 'package:photos/models/collection/collection.dart'; import 'package:photos/models/collection/collection_items.dart'; import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; import 'package:photos/models/file/file_type.dart'; import "package:photos/models/local_entity_data.dart"; import "package:photos/models/location_tag/location_tag.dart"; import 'package:photos/models/search/album_search_result.dart'; import 'package:photos/models/search/generic_search_result.dart'; import "package:photos/models/search/search_types.dart"; import 'package:photos/services/collections_service.dart'; import "package:photos/services/location_service.dart"; import "package:photos/states/location_screen_state.dart"; import "package:photos/ui/viewer/location/location_screen.dart"; import 'package:photos/utils/date_time_util.dart'; import "package:photos/utils/navigation_util.dart"; import 'package:tuple/tuple.dart'; class SearchService { Future>? _cachedFilesFuture; final _logger = Logger((SearchService).toString()); final _collectionService = CollectionsService.instance; static const _maximumResultsLimit = 20; SearchService._privateConstructor(); static final SearchService instance = SearchService._privateConstructor(); void init() { Bus.instance.on().listen((event) { // only invalidate, let the load happen on demand _cachedFilesFuture = null; }); } Set ignoreCollections() { return CollectionsService.instance.getHiddenCollectionIds(); } Future> getAllFiles() async { if (_cachedFilesFuture != null) { return _cachedFilesFuture!; } _logger.fine("Reading all files from db"); _cachedFilesFuture = FilesDB.instance.getAllFilesFromDB(ignoreCollections()); return _cachedFilesFuture!; } void clearCache() { _cachedFilesFuture = null; } // getFilteredCollectionsWithThumbnail removes deleted or archived or // collections which don't have a file from search result Future> getCollectionSearchResults( String query, ) async { final List collections = _collectionService.getCollectionsForUI( includedShared: true, ); final List collectionSearchResults = []; for (var c in collections) { if (collectionSearchResults.length >= _maximumResultsLimit) { break; } if (!c.isHidden() && c.type != CollectionType.uncategorized && c.displayName.toLowerCase().contains( query.toLowerCase(), )) { final EnteFile? thumbnail = await _collectionService.getCover(c); collectionSearchResults .add(AlbumSearchResult(CollectionWithThumbnail(c, thumbnail))); } } return collectionSearchResults; } Future> getAllCollectionSearchResults( int? limit, ) async { try { final List collections = _collectionService.getCollectionsForUI( includedShared: true, ); final List collectionSearchResults = []; for (var c in collections) { if (limit != null && collectionSearchResults.length >= limit) { break; } if (!c.isHidden() && c.type != CollectionType.uncategorized) { final EnteFile? thumbnail = await _collectionService.getCover(c); collectionSearchResults .add(AlbumSearchResult(CollectionWithThumbnail(c, thumbnail))); } } return collectionSearchResults; } catch (e) { _logger.severe("error gettin allCollectionSearchResults", e); return []; } } Future> getYearSearchResults( String yearFromQuery, ) async { final List searchResults = []; for (var yearData in YearsData.instance.yearsData) { if (yearData.year.startsWith(yearFromQuery)) { final List filesInYear = await _getFilesInYear(yearData.duration); if (filesInYear.isNotEmpty) { searchResults.add( GenericSearchResult( ResultType.year, yearData.year, filesInYear, ), ); } } } return searchResults; } Future> getRandomMomentsSearchResults( BuildContext context, ) async { try { final nonNullSearchResults = []; final randomYear = getRadomYearSearchResult(); final randomMonth = getRandomMonthSearchResult(context); final randomDate = getRandomDateResults(context); final randomHoliday = getRandomHolidaySearchResult(context); final searchResults = await Future.wait( [randomYear, randomMonth, randomDate, randomHoliday], ); for (GenericSearchResult? searchResult in searchResults) { if (searchResult != null) { nonNullSearchResults.add(searchResult); } } return nonNullSearchResults; } catch (e) { _logger.severe("Error getting RandomMomentsSearchResult", e); return []; } } Future getRadomYearSearchResult() async { for (var yearData in YearsData.instance.yearsData..shuffle()) { final List filesInYear = await _getFilesInYear(yearData.duration); if (filesInYear.isNotEmpty) { return GenericSearchResult( ResultType.year, yearData.year, filesInYear, ); } } //todo this throws error return null; } Future> getMonthSearchResults( BuildContext context, String query, ) async { final List searchResults = []; for (var month in _getMatchingMonths(context, query)) { final matchedFiles = await FilesDB.instance.getFilesCreatedWithinDurations( _getDurationsOfMonthInEveryYear(month.monthNumber), ignoreCollections(), order: 'DESC', ); if (matchedFiles.isNotEmpty) { searchResults.add( GenericSearchResult( ResultType.month, month.name, matchedFiles, ), ); } } return searchResults; } Future getRandomMonthSearchResult( BuildContext context, ) async { final months = getMonthData(context)..shuffle(); for (MonthData month in months) { final matchedFiles = await FilesDB.instance.getFilesCreatedWithinDurations( _getDurationsOfMonthInEveryYear(month.monthNumber), ignoreCollections(), order: 'DESC', ); if (matchedFiles.isNotEmpty) { return GenericSearchResult( ResultType.month, month.name, matchedFiles, ); } } return null; } Future> getHolidaySearchResults( BuildContext context, String query, ) async { final List searchResults = []; if (query.isEmpty) { return searchResults; } final holidays = getHolidays(context); for (var holiday in holidays) { if (holiday.name.toLowerCase().contains(query.toLowerCase())) { final matchedFiles = await FilesDB.instance.getFilesCreatedWithinDurations( _getDurationsForCalendarDateInEveryYear(holiday.day, holiday.month), ignoreCollections(), order: 'DESC', ); if (matchedFiles.isNotEmpty) { searchResults.add( GenericSearchResult(ResultType.event, holiday.name, matchedFiles), ); } } } return searchResults; } Future getRandomHolidaySearchResult( BuildContext context, ) async { final holidays = getHolidays(context)..shuffle(); for (var holiday in holidays) { final matchedFiles = await FilesDB.instance.getFilesCreatedWithinDurations( _getDurationsForCalendarDateInEveryYear(holiday.day, holiday.month), ignoreCollections(), order: 'DESC', ); if (matchedFiles.isNotEmpty) { return GenericSearchResult( ResultType.event, holiday.name, matchedFiles, ); } } return null; } Future> getFileTypeResults( String query, ) async { final List searchResults = []; final List allFiles = await getAllFiles(); for (var fileType in FileType.values) { final String fileTypeString = getHumanReadableString(fileType); if (fileTypeString.toLowerCase().startsWith(query.toLowerCase())) { final matchedFiles = allFiles.where((e) => e.fileType == fileType).toList(); if (matchedFiles.isNotEmpty) { searchResults.add( GenericSearchResult( ResultType.fileType, fileTypeString, matchedFiles, ), ); } } } return searchResults; } Future> getAllFileTypesAndExtensionsResults( int? limit, ) async { final List searchResults = []; final List allFiles = await getAllFiles(); final fileTypesAndMatchingFiles = >{}; final extensionsAndMatchingFiles = >{}; try { for (EnteFile file in allFiles) { if (!fileTypesAndMatchingFiles.containsKey(file.fileType)) { fileTypesAndMatchingFiles[file.fileType] = []; } fileTypesAndMatchingFiles[file.fileType]!.add(file); final String fileName = file.displayName; late final String ext; //Noticed that some old edited files do not have extensions and a '.' ext = fileName.contains(".") ? fileName.split(".").last.toUpperCase() : ""; if (ext != "") { if (!extensionsAndMatchingFiles.containsKey(ext)) { extensionsAndMatchingFiles[ext] = []; } extensionsAndMatchingFiles[ext]!.add(file); } } fileTypesAndMatchingFiles.forEach((key, value) { searchResults .add(GenericSearchResult(ResultType.fileType, key.name, value)); }); extensionsAndMatchingFiles.forEach((key, value) { searchResults .add(GenericSearchResult(ResultType.fileExtension, key, value)); }); if (limit != null) { return (searchResults..shuffle()) .sublist(0, min(limit, searchResults.length)); } else { return searchResults; } } catch (e) { _logger.severe("Error getting allFileTypesAndExtensionsResults", e); return []; } } ///Todo: Optimise + make this function more readable //This can be furthur optimized by not just limiting keys to 0 and 1. Use key //0 for single word, 1 for 2 word, 2 for 3 ..... and only check the substrings //in higher key if there are matches in the lower key. Future> getAllDescriptionSearchResults( //todo: use limit int? limit, ) async { try { final List searchResults = []; final List allFiles = await getAllFiles(); //each list element will be substrings from a description mapped by //word count = 1 and word count > 1 //New items will be added to [orderedSubDescriptions] list for every //distinct description. //[orderedSubDescriptions[x]] has two keys, 0 & 1. Value of key 0 will be single //word substrings. Value of key 1 will be multi word subStrings. When //iterating through [allFiles], we check for matching substrings from //[orderedSubDescriptions[x]] with the file's description. Starts from value //of key 0 (x=0). If there are no substring matches from key 0, there will //be none from key 1 as well. So these two keys are for avoiding unnecessary //checking of all subDescriptions with file description. final orderedSubDescs = >>[]; final descAndMatchingFiles = >{}; int distinctFullDescCount = 0; final allDistinctFullDescs = []; for (EnteFile file in allFiles) { if (file.caption != null && file.caption!.isNotEmpty) { //This limit doesn't necessarily have to be the limit parameter of the //method. Using the same variable to avoid unwanted iterations when //iterating over [orderedSubDescriptions] in case there is a limit //passed. Using the limit passed here so that there will be almost //always be more than 7 descriptionAndMatchingFiles and can shuffle //and choose only limited elements from it. Without shuffling, //result will be ["hello", "world", "hello world"] for the string //"hello world" if (limit == null || distinctFullDescCount < limit) { final descAlreadyRecorded = allDistinctFullDescs .any((element) => element.contains(file.caption!.trim())); if (!descAlreadyRecorded) { distinctFullDescCount++; allDistinctFullDescs.add(file.caption!.trim()); final words = file.caption!.trim().split(" "); orderedSubDescs.add({0: [], 1: []}); for (int i = 1; i <= words.length; i++) { for (int j = 0; j <= words.length - i; j++) { final subList = words.sublist(j, j + i); final substring = subList.join(" ").toLowerCase(); if (i == 1) { orderedSubDescs.last[0]!.add(substring); } else { orderedSubDescs.last[1]!.add(substring); } } } } } for (Map> orderedSubDescription in orderedSubDescs) { bool matchesSingleWordSubString = false; for (String subDescription in orderedSubDescription[0]!) { if (file.caption!.toLowerCase().contains(subDescription)) { matchesSingleWordSubString = true; //continue only after setting [matchesSingleWordSubString] to true if (subDescription.isAllConnectWords || subDescription.isLastWordConnectWord) continue; if (descAndMatchingFiles.containsKey(subDescription)) { descAndMatchingFiles[subDescription]!.add(file); } else { descAndMatchingFiles[subDescription] = {file}; } } } if (matchesSingleWordSubString) { for (String subDescription in orderedSubDescription[1]!) { if (subDescription.isAllConnectWords || subDescription.isLastWordConnectWord) continue; if (file.caption!.toLowerCase().contains(subDescription)) { if (descAndMatchingFiles.containsKey(subDescription)) { descAndMatchingFiles[subDescription]!.add(file); } else { descAndMatchingFiles[subDescription] = {file}; } } } } } } } ///[relevantDescAndFiles] will be a filterd version of [descriptionAndMatchingFiles] ///In [descriptionAndMatchingFiles], there will be descriptions with the same ///set of matching files. These descriptions will be substrings of a full ///description. [relevantDescAndFiles] will keep only the entry which has the ///longest description among enties with matching set of files. final relevantDescAndFiles = >{}; while (descAndMatchingFiles.isNotEmpty) { final baseEntry = descAndMatchingFiles.entries.first; final descsWithSameFiles = >{}; final baseUploadedFileIDs = baseEntry.value.map((e) => e.uploadedFileID).toSet(); descAndMatchingFiles.forEach((desc, files) { final uploadedFileIDs = files.map((e) => e.uploadedFileID).toSet(); final hasSameFiles = uploadedFileIDs.containsAll(baseUploadedFileIDs) && baseUploadedFileIDs.containsAll(uploadedFileIDs); if (hasSameFiles) { descsWithSameFiles.addAll({desc: files}); } }); descAndMatchingFiles .removeWhere((desc, files) => descsWithSameFiles.containsKey(desc)); final longestDescription = descsWithSameFiles.keys.reduce( (desc1, desc2) => desc1.length > desc2.length ? desc1 : desc2, ); relevantDescAndFiles.addAll( {longestDescription: descsWithSameFiles[longestDescription]!}, ); } relevantDescAndFiles.forEach((key, value) { searchResults.add( GenericSearchResult(ResultType.fileCaption, key, value.toList()), ); }); if (limit != null) { return (searchResults..shuffle()) .sublist(0, min(limit, searchResults.length)); } else { return searchResults; } } catch (e) { _logger.severe("Error in getAllDescriptionSearchResults", e); return []; } } Future> getCaptionAndNameResults( String query, ) async { final List searchResults = []; if (query.isEmpty) { return searchResults; } final RegExp pattern = RegExp(query, caseSensitive: false); final List allFiles = await getAllFiles(); final List captionMatch = []; final List displayNameMatch = []; for (EnteFile eachFile in allFiles) { if (eachFile.caption != null && pattern.hasMatch(eachFile.caption!)) { captionMatch.add(eachFile); } if (pattern.hasMatch(eachFile.displayName)) { displayNameMatch.add(eachFile); } } if (captionMatch.isNotEmpty) { searchResults.add( GenericSearchResult( ResultType.fileCaption, query, captionMatch, ), ); } if (displayNameMatch.isNotEmpty) { searchResults.add( GenericSearchResult( ResultType.file, query, displayNameMatch, ), ); } return searchResults; } Future> getFileExtensionResults( String query, ) async { final List searchResults = []; if (!query.startsWith(".")) { return searchResults; } final List allFiles = await getAllFiles(); final Map> resultMap = >{}; for (EnteFile eachFile in allFiles) { final String fileName = eachFile.displayName; if (fileName.contains(query)) { final String exnType = fileName.split(".").last.toUpperCase(); if (!resultMap.containsKey(exnType)) { resultMap[exnType] = []; } resultMap[exnType]!.add(eachFile); } } for (MapEntry> entry in resultMap.entries) { searchResults.add( GenericSearchResult( ResultType.fileExtension, entry.key.toUpperCase(), entry.value, ), ); } return searchResults; } Future> getLocationResults( String query, ) async { final locationTagEntities = (await LocationService.instance.getLocationTags()); final Map, List> result = {}; final bool showNoLocationTag = query.length > 2 && "No Location Tag".toLowerCase().startsWith(query.toLowerCase()); final List searchResults = []; for (LocalEntity tag in locationTagEntities) { if (tag.item.name.toLowerCase().contains(query.toLowerCase())) { result[tag] = []; } } if (result.isEmpty && !showNoLocationTag) { return searchResults; } final allFiles = await getAllFiles(); for (EnteFile file in allFiles) { if (file.hasLocation) { for (LocalEntity tag in result.keys) { if (LocationService.instance.isFileInsideLocationTag( tag.item.centerPoint, file.location!, tag.item.radius, )) { result[tag]!.add(file); } } } } if (showNoLocationTag) { _logger.fine("finding photos with no location"); // find files that have location but the file's location is not inside // any location tag final noLocationTagFiles = allFiles.where((file) { if (!file.hasLocation) { return false; } for (LocalEntity tag in locationTagEntities) { if (LocationService.instance.isFileInsideLocationTag( tag.item.centerPoint, file.location!, tag.item.radius, )) { return false; } } return true; }).toList(); if (noLocationTagFiles.isNotEmpty) { searchResults.add( GenericSearchResult( ResultType.fileType, "No Location Tag", noLocationTagFiles, ), ); } } for (MapEntry, List> entry in result.entries) { if (entry.value.isNotEmpty) { searchResults.add( GenericSearchResult( ResultType.location, entry.key.item.name, entry.value, onResultTap: (ctx) { routeToPage( ctx, LocationScreenStateProvider( entry.key, const LocationScreen(), ), ); }, ), ); } } return searchResults; } Future> getAllLocationTags(int? limit) async { try { final Map, List> tagToItemsMap = {}; final List tagSearchResults = []; final locationTagEntities = (await LocationService.instance.getLocationTags()); final allFiles = await getAllFiles(); for (int i = 0; i < locationTagEntities.length; i++) { if (limit != null && i >= limit) break; tagToItemsMap[locationTagEntities.elementAt(i)] = []; } for (EnteFile file in allFiles) { if (file.hasLocation) { for (LocalEntity tag in tagToItemsMap.keys) { if (LocationService.instance.isFileInsideLocationTag( tag.item.centerPoint, file.location!, tag.item.radius, )) { tagToItemsMap[tag]!.add(file); } } } } for (MapEntry, List> entry in tagToItemsMap.entries) { if (entry.value.isNotEmpty) { tagSearchResults.add( GenericSearchResult( ResultType.location, entry.key.item.name, entry.value, onResultTap: (ctx) { routeToPage( ctx, LocationScreenStateProvider( entry.key, const LocationScreen(), ), ); }, ), ); } } return tagSearchResults; } catch (e) { _logger.severe("Error in getAllLocationTags", e); return []; } } Future> getDateResults( BuildContext context, String query, ) async { final List searchResults = []; final potentialDates = _getPossibleEventDate(context, query); for (var potentialDate in potentialDates) { final int day = potentialDate.item1; final int month = potentialDate.item2.monthNumber; final int? year = potentialDate.item3; // nullable final matchedFiles = await FilesDB.instance.getFilesCreatedWithinDurations( _getDurationsForCalendarDateInEveryYear(day, month, year: year), ignoreCollections(), order: 'DESC', ); if (matchedFiles.isNotEmpty) { searchResults.add( GenericSearchResult( ResultType.event, '$day ${potentialDate.item2.name} ${year ?? ''}', matchedFiles, ), ); } } return searchResults; } Future getRandomDateResults( BuildContext context, ) async { final allFiles = await getAllFiles(); if (allFiles.isEmpty) return null; final length = allFiles.length; final randomFile = allFiles[Random().nextInt(length)]; final creationTime = randomFile.creationTime!; final originalDateTime = DateTime.fromMicrosecondsSinceEpoch(creationTime); final startOfDay = DateTime( originalDateTime.year, originalDateTime.month, originalDateTime.day, ); final endOfDay = DateTime( originalDateTime.year, originalDateTime.month, originalDateTime.day + 1, ); final durationOfDay = [ startOfDay.microsecondsSinceEpoch, endOfDay.microsecondsSinceEpoch, ]; final matchedFiles = await FilesDB.instance.getFilesCreatedWithinDurations( [durationOfDay], ignoreCollections(), order: 'DESC', ); return GenericSearchResult( ResultType.event, DateFormat.yMMMd(Localizations.localeOf(context).languageCode).format( DateTime.fromMicrosecondsSinceEpoch(creationTime).toLocal(), ), matchedFiles, ); } Future> getAllPeopleSearchResults( int? limit, ) async { try { final searchResults = []; final allFiles = await getAllFiles(); final peopleToSharedFiles = >{}; int peopleCount = 0; for (EnteFile file in allFiles) { if (file.isOwner) continue; final fileOwner = CollectionsService.instance .getFileOwner(file.ownerID!, file.collectionID); if (peopleToSharedFiles.containsKey(fileOwner)) { peopleToSharedFiles[fileOwner]!.add(file); } else { if (limit != null && limit <= peopleCount) continue; peopleToSharedFiles[fileOwner] = [file]; peopleCount++; } } peopleToSharedFiles.forEach((key, value) { searchResults.add( GenericSearchResult( ResultType.shared, key.name != null && key.name!.isNotEmpty ? key.name! : key.email, value, ), ); }); return searchResults; } catch (e) { _logger.severe("Error in getAllLocationTags", e); return []; } } List _getMatchingMonths(BuildContext context, String query) { return getMonthData(context) .where( (monthData) => monthData.name.toLowerCase().startsWith(query.toLowerCase()), ) .toList(); } Future> _getFilesInYear(List durationOfYear) async { return await FilesDB.instance.getFilesCreatedWithinDurations( [durationOfYear], ignoreCollections(), order: "DESC", ); } List> _getDurationsForCalendarDateInEveryYear( int day, int month, { int? year, }) { final List> durationsOfHolidayInEveryYear = []; final int startYear = year ?? searchStartYear; final int endYear = year ?? currentYear; for (var yr = startYear; yr <= endYear; yr++) { if (isValidGregorianDate(day: day, month: month, year: yr)) { durationsOfHolidayInEveryYear.add([ DateTime(yr, month, day).microsecondsSinceEpoch, DateTime(yr, month, day + 1).microsecondsSinceEpoch, ]); } } return durationsOfHolidayInEveryYear; } List> _getDurationsOfMonthInEveryYear(int month) { final List> durationsOfMonthInEveryYear = []; for (var year = searchStartYear; year <= currentYear; year++) { durationsOfMonthInEveryYear.add([ DateTime.utc(year, month, 1).microsecondsSinceEpoch, month == 12 ? DateTime(year + 1, 1, 1).microsecondsSinceEpoch : DateTime(year, month + 1, 1).microsecondsSinceEpoch, ]); } return durationsOfMonthInEveryYear; } List> _getPossibleEventDate( BuildContext context, String query, ) { final List> possibleEvents = []; if (query.trim().isEmpty) { return possibleEvents; } final result = query .trim() .split(RegExp('[ ,-/]+')) .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); final resultCount = result.length; if (resultCount < 1 || resultCount > 4) { return possibleEvents; } final int? day = int.tryParse(result[0]); if (day == null || day < 1 || day > 31) { return possibleEvents; } final List potentialMonth = resultCount > 1 ? _getMatchingMonths(context, result[1]) : getMonthData(context); final int? parsedYear = resultCount >= 3 ? int.tryParse(result[2]) : null; final List matchingYears = []; if (parsedYear != null) { bool foundMatch = false; for (int i = searchStartYear; i <= currentYear; i++) { if (i.toString().startsWith(parsedYear.toString())) { matchingYears.add(i); foundMatch = foundMatch || (i == parsedYear); } } if (!foundMatch && parsedYear > 1000 && parsedYear <= currentYear) { matchingYears.add(parsedYear); } } for (var element in potentialMonth) { if (matchingYears.isEmpty) { possibleEvents.add(Tuple3(day, element, null)); } else { for (int yr in matchingYears) { possibleEvents.add(Tuple3(day, element, yr)); } } } return possibleEvents; } }