// @dart=2.9 import 'package:logging/logging.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/core/network.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/models/collection_items.dart'; import 'package:photos/models/file.dart'; import 'package:photos/models/file_type.dart'; import 'package:photos/models/location.dart'; import 'package:photos/models/search/album_search_result.dart'; import 'package:photos/models/search/generic_search_result.dart'; import 'package:photos/models/search/location_api_response.dart'; import 'package:photos/models/search/search_result.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/utils/date_time_util.dart'; import 'package:tuple/tuple.dart'; class SearchService { Future> _cachedFilesFuture; final _enteDio = Network.instance.enteDio; 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.getHiddenCollections(); } Future> _getAllFiles() async { if (_cachedFilesFuture != null) { return _cachedFilesFuture; } _logger.fine("Reading all files from db"); _cachedFilesFuture = FilesDB.instance.getAllFilesFromDB(ignoreCollections()); return _cachedFilesFuture; } Future> getFileSearchResults(String query) async { final List fileSearchResults = []; final List files = await _getAllFiles(); for (var file in files) { if (fileSearchResults.length >= _maximumResultsLimit) { break; } if (file.title.toLowerCase().contains(query.toLowerCase())) { fileSearchResults.add(file); } } return fileSearchResults; } void clearCache() { _cachedFilesFuture = null; } Future> getLocationSearchResults( String query, ) async { final List searchResults = []; try { final List allFiles = await _getAllFiles(); final response = await _enteDio.get( "/search/location", queryParameters: {"query": query, "limit": 10}, ); final matchedLocationSearchResults = LocationApiResponse.fromMap(response.data); for (var locationData in matchedLocationSearchResults.results) { final List filesInLocation = []; for (var file in allFiles) { if (_isValidLocation(file.location) && _isLocationWithinBounds(file.location, locationData)) { filesInLocation.add(file); } } filesInLocation.sort( (first, second) => second.creationTime.compareTo(first.creationTime), ); if (filesInLocation.isNotEmpty) { searchResults.add( GenericSearchResult( ResultType.location, locationData.place, filesInLocation, ), ); } } } catch (e) { _logger.severe(e); } return searchResults; } // getFilteredCollectionsWithThumbnail removes deleted or archived or // collections which don't have a file from search result Future> getCollectionSearchResults( String query, ) async { final List collectionWithThumbnails = await _collectionService.getCollectionsWithThumbnails( includedOwnedByOthers: true, ); final List collectionSearchResults = []; for (var c in collectionWithThumbnails) { if (collectionSearchResults.length >= _maximumResultsLimit) { break; } if (!c.collection.isHidden() && c.collection.name.toLowerCase().contains( query.toLowerCase(), )) { collectionSearchResults.add(AlbumSearchResult(c)); } } return collectionSearchResults; } 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> getHolidaySearchResults( String query, ) async { final List searchResults = []; for (var holiday in allHolidays) { 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> 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> getCaptionResults( String query, ) async { final List searchResults = []; if (query.isEmpty) { return searchResults; } final RegExp pattern = RegExp(query, caseSensitive: false); final List allFiles = await _getAllFiles(); final matchedFiles = allFiles .where((e) => e.caption != null && pattern.hasMatch(e.caption)) .toList(); if (matchedFiles.isNotEmpty) { searchResults.add( GenericSearchResult( ResultType.fileCaption, query, matchedFiles, ), ); } return searchResults; } Future> getFileExtensionResults( String query, ) async { final List searchResults = []; if (!query.startsWith(".")) { return searchResults; } final List allFiles = await _getAllFiles(); final Map> resultMap = >{}; for (File 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> getMonthSearchResults(String query) async { final List searchResults = []; for (var month in _getMatchingMonths(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> getDateResults( String query, ) async { final List searchResults = []; final potentialDates = _getPossibleEventDate(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; } List _getMatchingMonths(String query) { return allMonths .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 (isValidDate(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; } bool _isValidLocation(Location location) { return location != null && location.latitude != null && location.latitude != 0 && location.longitude != null && location.longitude != 0; } bool _isLocationWithinBounds( Location location, LocationDataFromResponse locationData, ) { //format returned by the api is [lng,lat,lng,lat] where indexes 0 & 1 are southwest and 2 & 3 northeast return location.longitude > locationData.bbox[0] && location.latitude > locationData.bbox[1] && location.longitude < locationData.bbox[2] && location.latitude < locationData.bbox[3]; } List> _getPossibleEventDate(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(result[1]) : allMonths; 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; } }