import "dart:math"; import "package:flutter/cupertino.dart"; import "package:flutter/material.dart"; import "package:intl/intl.dart"; import 'package:logging/logging.dart'; import "package:ml_linalg/linalg.dart"; import "package:photos/core/constants.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/db/ml/db.dart"; import 'package:photos/events/local_photos_updated_event.dart'; import "package:photos/extensions/user_extension.dart"; import "package:photos/models/api/collection/user.dart"; import "package:photos/models/base_location.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/location.dart"; import "package:photos/models/location_tag/location_tag.dart"; import "package:photos/models/ml/face/person.dart"; import 'package:photos/models/search/album_search_result.dart'; import 'package:photos/models/search/generic_search_result.dart'; import "package:photos/models/search/hierarchical/contacts_filter.dart"; import "package:photos/models/search/hierarchical/face_filter.dart"; import "package:photos/models/search/hierarchical/file_type_filter.dart"; import "package:photos/models/search/hierarchical/hierarchical_search_filter.dart"; import "package:photos/models/search/hierarchical/location_filter.dart"; import "package:photos/models/search/hierarchical/magic_filter.dart"; import "package:photos/models/search/hierarchical/top_level_generic_filter.dart"; import "package:photos/models/search/search_constants.dart"; import "package:photos/models/search/search_types.dart"; import "package:photos/models/trip_memory.dart"; import "package:photos/service_locator.dart"; import "package:photos/services/account/user_service.dart"; import 'package:photos/services/collections_service.dart'; import "package:photos/services/filter/db_filters.dart"; import "package:photos/services/location_service.dart"; import "package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart"; import "package:photos/services/machine_learning/face_ml/person/person_service.dart"; import "package:photos/services/machine_learning/ml_computer.dart"; import 'package:photos/services/machine_learning/semantic_search/semantic_search_service.dart'; import "package:photos/states/location_screen_state.dart"; import "package:photos/ui/viewer/location/add_location_sheet.dart"; import "package:photos/ui/viewer/location/location_screen.dart"; import "package:photos/ui/viewer/people/cluster_page.dart"; import "package:photos/ui/viewer/people/people_page.dart"; import "package:photos/ui/viewer/search/result/magic_result_screen.dart"; import 'package:photos/utils/date_time_util.dart'; import "package:photos/utils/file_util.dart"; import "package:photos/utils/navigation_util.dart"; import 'package:tuple/tuple.dart'; class SearchService { Future>? _cachedFilesFuture; Future>? _cachedFilesForSearch; Future>? _cachedFilesForHierarchicalSearch; Future>? _cachedHiddenFilesFuture; final _logger = Logger((SearchService).toString()); final _collectionService = CollectionsService.instance; static const _maximumResultsLimit = 20; late final mlDataDB = MLDataDB.instance; 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; _cachedFilesForSearch = null; _cachedFilesForHierarchicalSearch = null; _cachedHiddenFilesFuture = null; }); } Set ignoreCollections() { return CollectionsService.instance.getHiddenCollectionIds(); } Future> getAllFilesForSearch() async { if (_cachedFilesFuture != null && _cachedFilesForSearch != null) { return _cachedFilesForSearch!; } if (_cachedFilesFuture == null) { _logger.fine("Reading all files from db"); _cachedFilesFuture = FilesDB.instance.getAllFilesFromDB( ignoreCollections(), dedupeByUploadId: false, ); } _cachedFilesForSearch = _cachedFilesFuture!.then((files) { return applyDBFilters( files, DBFilterOptions( dedupeUploadID: true, ), ); }); return _cachedFilesForSearch!; } Future> getAllFilesForHierarchicalSearch() async { if (_cachedFilesFuture != null && _cachedFilesForHierarchicalSearch != null) { return _cachedFilesForHierarchicalSearch!; } if (_cachedFilesFuture == null) { _logger.fine("Reading all files from db"); _cachedFilesFuture = FilesDB.instance.getAllFilesFromDB( ignoreCollections(), dedupeByUploadId: false, ); } _cachedFilesForHierarchicalSearch = _cachedFilesFuture!.then((files) { return applyDBFilters( files, DBFilterOptions( dedupeUploadID: false, onlyUploadedFiles: true, ), ); }); return _cachedFilesForHierarchicalSearch!; } Future> getHiddenFiles() async { if (_cachedHiddenFilesFuture != null) { return _cachedHiddenFilesFuture!; } _logger.fine("Reading hidden files from db"); final hiddenCollections = CollectionsService.instance.getHiddenCollectionIds(); _cachedHiddenFilesFuture = FilesDB.instance.getAllFilesFromCollections(hiddenCollections); return _cachedHiddenFilesFuture!; } void clearCache() { _cachedFilesFuture = null; _cachedFilesForSearch = null; _cachedFilesForHierarchicalSearch = null; _cachedHiddenFilesFuture = 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, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: yearData.year, occurrence: kMostRelevantFilter, filterResultType: ResultType.year, matchedUploadedIDs: filesToUploadedFileIDs(filesInYear), filterIcon: Icons.calendar_month_outlined, ), ), ); } } } return searchResults; } Future> getMagicSectionResults( BuildContext context, ) async { if (flagService.hasGrantedMLConsent) { return magicCacheService.getMagicGenericSearchResult(context); } else { return []; } } 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, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: yearData.year, occurrence: kMostRelevantFilter, filterResultType: ResultType.year, matchedUploadedIDs: filesToUploadedFileIDs(filesInYear), filterIcon: Icons.calendar_month_outlined, ), ); } } //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, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: month.name, occurrence: kMostRelevantFilter, filterResultType: ResultType.month, matchedUploadedIDs: filesToUploadedFileIDs(matchedFiles), filterIcon: Icons.calendar_month_outlined, ), ), ); } } 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, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: month.name, occurrence: kMostRelevantFilter, filterResultType: ResultType.month, matchedUploadedIDs: filesToUploadedFileIDs(matchedFiles), filterIcon: Icons.calendar_month_outlined, ), ); } } 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, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: holiday.name, occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(matchedFiles), filterIcon: Icons.event_outlined, ), ), ); } } } 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, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: holiday.name, occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(matchedFiles), filterIcon: Icons.event_outlined, ), ); } } return null; } Future> getFileTypeResults( BuildContext context, String query, ) async { final List searchResults = []; final List allFiles = await getAllFilesForSearch(); for (var fileType in FileType.values) { final String fileTypeString = getHumanReadableString(context, 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, hierarchicalSearchFilter: FileTypeFilter( fileType: fileType, typeName: fileTypeString, occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(matchedFiles), ), ), ); } } } return searchResults; } Future> getAllFileTypesAndExtensionsResults( BuildContext context, int? limit, ) async { final List searchResults = []; final List allFiles = await getAllFilesForSearch(); 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) { final name = getHumanReadableString(context, key); searchResults.add( GenericSearchResult( ResultType.fileType, name, value, hierarchicalSearchFilter: FileTypeFilter( fileType: key, typeName: name, occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(value), ), ), ); }); extensionsAndMatchingFiles.forEach((key, value) { searchResults.add( GenericSearchResult( ResultType.fileExtension, key + "s", value, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: key + "s", occurrence: kMostRelevantFilter, filterResultType: ResultType.fileExtension, matchedUploadedIDs: filesToUploadedFileIDs(value), filterIcon: CupertinoIcons.doc_text, ), ), ); }); if (limit != null) { return searchResults.sublist(0, min(limit, searchResults.length)); } else { return searchResults; } } catch (e) { _logger.severe("Error getting allFileTypesAndExtensionsResults", 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 getAllFilesForSearch(); 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, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: query, occurrence: kMostRelevantFilter, filterResultType: ResultType.fileCaption, matchedUploadedIDs: filesToUploadedFileIDs(captionMatch), filterIcon: Icons.description_outlined, ), ), ); } if (displayNameMatch.isNotEmpty) { searchResults.add( GenericSearchResult( ResultType.file, query, displayNameMatch, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: query, occurrence: kMostRelevantFilter, filterResultType: ResultType.file, matchedUploadedIDs: filesToUploadedFileIDs(displayNameMatch), ), ), ); } return searchResults; } Future> getFileExtensionResults( String query, ) async { final List searchResults = []; if (!query.startsWith(".")) { return searchResults; } final List allFiles = await getAllFilesForSearch(); 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, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: entry.key.toUpperCase(), occurrence: kMostRelevantFilter, filterResultType: ResultType.fileExtension, matchedUploadedIDs: filesToUploadedFileIDs(entry.value), filterIcon: CupertinoIcons.doc_text, ), ), ); } return searchResults; } Future> getLocationResults(String query) async { final locationTagEntities = (await locationService.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] = []; } } final allFiles = await getAllFilesForSearch(); for (EnteFile file in allFiles) { if (file.hasLocation) { for (LocalEntity tag in result.keys) { if (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 (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, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: "No Location Tag", occurrence: kMostRelevantFilter, filterResultType: ResultType.fileType, matchedUploadedIDs: filesToUploadedFileIDs(noLocationTagFiles), filterIcon: Icons.not_listed_location_outlined, ), ), ); } } final locationTagNames = {}; for (MapEntry, List> entry in result.entries) { if (entry.value.isNotEmpty) { final name = entry.key.item.name; locationTagNames.add(name); searchResults.add( GenericSearchResult( ResultType.location, name, entry.value, onResultTap: (ctx) { routeToPage( ctx, LocationScreenStateProvider( entry.key, const LocationScreen(), ), ); }, hierarchicalSearchFilter: LocationFilter( locationTag: entry.key.item, occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(entry.value), ), ), ); } } //todo: remove this later, this hack is for interval+external evaluation // for suggestions final allCitiesSearch = query == '__city'; if (allCitiesSearch) { query = ''; } final results = await locationService.getFilesInCity(allFiles, query); final List sortedByResultCount = results.keys.toList() ..sort((a, b) => results[b]!.length.compareTo(results[a]!.length)); for (final city in sortedByResultCount) { // If the location tag already exists for a city, don't add it again if (!locationTagNames.contains(city.city)) { final a = (defaultCityRadius * scaleFactor(city.lat)) / kilometersPerDegree; const b = defaultCityRadius / kilometersPerDegree; searchResults.add( GenericSearchResult( ResultType.location, city.city, results[city]!, hierarchicalSearchFilter: LocationFilter( locationTag: LocationTag( name: city.city, radius: defaultCityRadius, centerPoint: Location(latitude: city.lat, longitude: city.lng), aSquare: a * a, bSquare: b * b, ), occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(results[city]!), ), ), ); } } return searchResults; } Future>> getClusterFilesForPersonID( String personID, ) async { _logger.info('getClusterFilesForPersonID $personID'); final Map> fileIdToClusterID = await mlDataDB.getFileIdToClusterIDSet(personID); _logger.info('faceDbDone getClusterFilesForPersonID $personID'); final Map> clusterIDToFiles = {}; final allFiles = await getAllFilesForSearch(); for (final f in allFiles) { if (!fileIdToClusterID.containsKey(f.uploadedFileID ?? -1)) { continue; } final cluserIds = fileIdToClusterID[f.uploadedFileID ?? -1]!; for (final cluster in cluserIds) { if (clusterIDToFiles.containsKey(cluster)) { clusterIDToFiles[cluster]!.add(f); } else { clusterIDToFiles[cluster] = [f]; } } } _logger.info('done getClusterFilesForPersonID $personID'); return clusterIDToFiles; } Future> getAllFace( int? limit, { int minClusterSize = kMinimumClusterSizeSearchResult, }) async { try { debugPrint("getting faces"); final Map> fileIdToClusterID = await mlDataDB.getFileIdToClusterIds(); final Map personIdToPerson = await PersonService.instance.getPersonsMap(); final clusterIDToPersonID = await mlDataDB.getClusterIDToPersonID(); final List facesResult = []; final Map> clusterIdToFiles = {}; final Map> personIdToFiles = {}; final allFiles = await getAllFilesForSearch(); for (final f in allFiles) { if (!fileIdToClusterID.containsKey(f.uploadedFileID ?? -1)) { continue; } final clusterIds = fileIdToClusterID[f.uploadedFileID ?? -1]!; for (final cluster in clusterIds) { final PersonEntity? p = personIdToPerson[clusterIDToPersonID[cluster] ?? ""]; if (p != null) { if (personIdToFiles.containsKey(p.remoteID)) { personIdToFiles[p.remoteID]!.add(f); } else { personIdToFiles[p.remoteID] = [f]; } } else { if (clusterIdToFiles.containsKey(cluster)) { clusterIdToFiles[cluster]!.add(f); } else { clusterIdToFiles[cluster] = [f]; } } } } // get sorted personId by files count final sortedPersonIds = personIdToFiles.keys.toList() ..sort( (a, b) => personIdToFiles[b]!.length.compareTo( personIdToFiles[a]!.length, ), ); for (final personID in sortedPersonIds) { final files = personIdToFiles[personID]!; if (files.isEmpty) { continue; } final PersonEntity p = personIdToPerson[personID]!; if (p.data.isIgnored) continue; facesResult.add( GenericSearchResult( ResultType.faces, p.data.name, files, params: { kPersonWidgetKey: p.data.avatarFaceID ?? p.hashCode.toString(), kPersonParamID: personID, kFileID: files.first.uploadedFileID, }, onResultTap: (ctx) { routeToPage( ctx, PeoplePage( tagPrefix: "${ResultType.faces.toString()}_${p.data.name}", person: p, searchResult: GenericSearchResult( ResultType.faces, p.data.name, files, params: { kPersonWidgetKey: p.data.avatarFaceID ?? p.hashCode.toString(), kPersonParamID: personID, kFileID: files.first.uploadedFileID, }, hierarchicalSearchFilter: FaceFilter( personId: p.remoteID, clusterId: null, faceName: p.data.name, faceFile: files.first, occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(files), ), ), ), ); }, hierarchicalSearchFilter: FaceFilter( personId: p.remoteID, clusterId: null, faceName: p.data.name, faceFile: files.first, occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(files), ), ), ); } final sortedClusterIds = clusterIdToFiles.keys.toList() ..sort( (a, b) => clusterIdToFiles[b]! .length .compareTo(clusterIdToFiles[a]!.length), ); for (final clusterId in sortedClusterIds) { final files = clusterIdToFiles[clusterId]!; // final String clusterName = "ID:$clusterId, ${files.length}"; // final String clusterName = "${files.length}"; // const String clusterName = ""; final String clusterName = clusterId; if (clusterIDToPersonID[clusterId] != null) { final String personID = clusterIDToPersonID[clusterId]!; final PersonEntity? p = personIdToPerson[personID]; if (p != null) { // This should not be possible since it should be handled in the above loop, logging just in case _logger.severe( "`getAllFace`: Something unexpected happened, Cluster $clusterId should not have person id $personID", Exception( 'Some unexpected error occurred in getAllFace wrt cluster to person mapping', ), ); } else { // This should not happen, means a clusterID is still assigned to a personID of a person that no longer exists // Logging the error and deleting the clusterID to personID mapping _logger.severe( "`getAllFace`: Cluster $clusterId should not have person id ${clusterIDToPersonID[clusterId]}, deleting the mapping", Exception('ClusterID assigned to a person that no longer exists'), ); await mlDataDB.removeClusterToPerson( personID: personID, clusterID: clusterId, ); } } if (files.length < minClusterSize) continue; facesResult.add( GenericSearchResult( ResultType.faces, "", files, params: { kClusterParamId: clusterId, kFileID: files.first.uploadedFileID, }, onResultTap: (ctx) { routeToPage( ctx, ClusterPage( files, tagPrefix: "${ResultType.faces.toString()}_$clusterName", clusterID: clusterId, ), ); }, hierarchicalSearchFilter: FaceFilter( personId: null, clusterId: clusterId, faceName: null, faceFile: files.first, occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(files), ), ), ); } if (facesResult.isEmpty) { int newMinimum = minClusterSize; for (final int minimum in kLowerMinimumClusterSizes) { if (minimum < minClusterSize) { newMinimum = minimum; break; } } if (newMinimum < minClusterSize) { return getAllFace(limit, minClusterSize: newMinimum); } else { return []; } } if (limit != null) { return facesResult.sublist(0, min(limit, facesResult.length)); } else { return facesResult; } } catch (e, s) { _logger.severe("Error in getAllFace", e, s); rethrow; } } Future> getAllLocationTags(int? limit) async { try { final Map, List> tagToItemsMap = {}; final List tagSearchResults = []; final locationTagEntities = (await locationService.getLocationTags()); final allFiles = await getAllFilesForSearch(); final List filesWithNoLocTag = []; 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) { bool hasLocationTag = false; for (LocalEntity tag in tagToItemsMap.keys) { if (isFileInsideLocationTag( tag.item.centerPoint, file.location!, tag.item.radius, )) { hasLocationTag = true; tagToItemsMap[tag]!.add(file); } } // If the location tag already exists for a city, do not consider // it for the city suggestions if (!hasLocationTag) { filesWithNoLocTag.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, LocationScreen( //this is SearchResult.heroTag() tagPrefix: "${ResultType.location.toString()}_${entry.key.item.name}", ), ), ); }, hierarchicalSearchFilter: LocationFilter( locationTag: entry.key.item, occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(entry.value), ), ), ); } } if (limit == null || tagSearchResults.length < limit) { final results = await locationService.getFilesInCity(filesWithNoLocTag, ''); final List sortedByResultCount = results.keys.toList() ..sort((a, b) => results[b]!.length.compareTo(results[a]!.length)); for (final city in sortedByResultCount) { if (results[city]!.length <= 1) continue; final a = (defaultCityRadius * scaleFactor(city.lat)) / kilometersPerDegree; const b = defaultCityRadius / kilometersPerDegree; tagSearchResults.add( GenericSearchResult( ResultType.locationSuggestion, city.city, results[city]!, onResultTap: (ctx) { showAddLocationSheet( ctx, Location(latitude: city.lat, longitude: city.lng), name: city.city, radius: defaultCityRadius, ); }, hierarchicalSearchFilter: LocationFilter( locationTag: LocationTag( name: city.city, radius: defaultCityRadius, centerPoint: Location(latitude: city.lat, longitude: city.lng), aSquare: a * a, bSquare: b * b, ), occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(results[city]!), ), ), ); } } 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) { final name = '$day ${potentialDate.item2.name} ${year ?? ''}'; searchResults.add( GenericSearchResult( ResultType.event, name, matchedFiles, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: name, occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(matchedFiles), filterIcon: Icons.event_outlined, ), ), ); } } return searchResults; } Future> getMagicSearchResults( BuildContext context, String query, ) async { final List searchResults = []; late List files; late String resultForQuery; try { (resultForQuery, files) = await SemanticSearchService.instance.searchScreenQuery(query); } catch (e, s) { _logger.severe("Error occurred during magic search", e, s); return searchResults; } if (files.isNotEmpty && resultForQuery == query) { searchResults.add( GenericSearchResult( ResultType.magic, query, files, onResultTap: (context) { routeToPage( context, MagicResultScreen( files, name: query, enableGrouping: false, heroTag: GenericSearchResult( ResultType.magic, query, files, hierarchicalSearchFilter: MagicFilter( filterName: query, occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(files), ), ).heroTag(), magicFilter: MagicFilter( filterName: query, occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(files), ), ), ); }, hierarchicalSearchFilter: MagicFilter( filterName: query, occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(files), ), ), ); } return searchResults; } Future> getTripsResults( BuildContext context, int? limit, ) async { final List searchResults = []; final allFiles = await getAllFilesForSearch(); final Iterable> locationTagEntities = (await locationService.getLocationTags()); if (allFiles.isEmpty) return []; final currentTime = DateTime.now().toLocal(); final currentMonth = currentTime.month; final cutOffTime = currentTime.subtract(const Duration(days: 365)); final Map, List> tagToItemsMap = {}; for (int i = 0; i < locationTagEntities.length; i++) { tagToItemsMap[locationTagEntities.elementAt(i)] = []; } final List<(List, Location)> smallRadiusClusters = []; final List<(List, Location)> wideRadiusClusters = []; // Go through all files and cluster the ones not inside any location tag for (EnteFile file in allFiles) { if (!file.hasLocation || file.uploadedFileID == null || !file.isOwner || file.creationTime == null) { continue; } // Check if the file is inside any location tag bool hasLocationTag = false; for (LocalEntity tag in tagToItemsMap.keys) { if (isFileInsideLocationTag( tag.item.centerPoint, file.location!, tag.item.radius, )) { hasLocationTag = true; tagToItemsMap[tag]!.add(file); } } // Cluster the files not inside any location tag (incremental clustering) if (!hasLocationTag) { // Small radius clustering for base locations bool foundSmallCluster = false; for (final cluster in smallRadiusClusters) { final clusterLocation = cluster.$2; if (isFileInsideLocationTag( clusterLocation, file.location!, 0.6, )) { cluster.$1.add(file); foundSmallCluster = true; break; } } if (!foundSmallCluster) { smallRadiusClusters.add(([file], file.location!)); } // Wide radius clustering for trip locations bool foundWideCluster = false; for (final cluster in wideRadiusClusters) { final clusterLocation = cluster.$2; if (isFileInsideLocationTag( clusterLocation, file.location!, 100.0, )) { cluster.$1.add(file); foundWideCluster = true; break; } } if (!foundWideCluster) { wideRadiusClusters.add(([file], file.location!)); } } } // Identify base locations final List baseLocations = []; for (final cluster in smallRadiusClusters) { final files = cluster.$1; final location = cluster.$2; // Check that the photos are distributed over a longer time range (3+ months) final creationTimes = []; final Set uniqueDays = {}; for (final file in files) { creationTimes.add(file.creationTime!); final date = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); final dayStamp = DateTime(date.year, date.month, date.day).microsecondsSinceEpoch; uniqueDays.add(dayStamp); } creationTimes.sort(); if (creationTimes.length < 10) continue; final firstCreationTime = DateTime.fromMicrosecondsSinceEpoch( creationTimes.first, ); final lastCreationTime = DateTime.fromMicrosecondsSinceEpoch( creationTimes.last, ); if (lastCreationTime.difference(firstCreationTime).inDays < 90) { continue; } // Check for a minimum average number of days photos are clicked in range final daysRange = lastCreationTime.difference(firstCreationTime).inDays; if (uniqueDays.length < daysRange * 0.1) continue; // Check if it's a current or old base location final bool isCurrent = lastCreationTime.isAfter( DateTime.now().subtract( const Duration(days: 90), ), ); baseLocations.add(BaseLocation(files, location, isCurrent)); } // Identify trip locations final List tripLocations = []; clusteredLocations: for (final cluster in wideRadiusClusters) { final files = cluster.$1; final location = cluster.$2; // Check that it's at least 10km away from any base or tag location bool tooClose = false; for (final baseLocation in baseLocations) { if (isFileInsideLocationTag( baseLocation.location, location, 10.0, )) { tooClose = true; break; } } for (final tag in tagToItemsMap.keys) { if (isFileInsideLocationTag( tag.item.centerPoint, location, 10.0, )) { tooClose = true; break; } } if (tooClose) continue clusteredLocations; // Check that the photos are distributed over a short time range (2-30 days) or multiple short time ranges only files.sort((a, b) => a.creationTime!.compareTo(b.creationTime!)); // Find distinct time blocks (potential trips) List currentBlockFiles = [files.first]; int blockStart = files.first.creationTime!; int lastTime = files.first.creationTime!; DateTime lastDateTime = DateTime.fromMicrosecondsSinceEpoch(lastTime); for (int i = 1; i < files.length; i++) { final currentFile = files[i]; final currentTime = currentFile.creationTime!; final gap = DateTime.fromMicrosecondsSinceEpoch(currentTime) .difference(lastDateTime) .inDays; // If gap is too large, end current block and check if it's a valid trip if (gap > 15) { // 10 days gap to separate trips. If gap is small, it's likely not a trip if (gap < 90) continue clusteredLocations; final blockDuration = lastDateTime .difference(DateTime.fromMicrosecondsSinceEpoch(blockStart)) .inDays; // Check if current block is a valid trip (2-30 days) if (blockDuration >= 2 && blockDuration <= 30) { tripLocations.add( TripMemory( List.from(currentBlockFiles), location, blockStart, lastTime, ), ); } // Start new block currentBlockFiles = []; blockStart = currentTime; } currentBlockFiles.add(currentFile); lastTime = currentTime; lastDateTime = DateTime.fromMicrosecondsSinceEpoch(lastTime); } // Check final block final lastBlockDuration = lastDateTime .difference(DateTime.fromMicrosecondsSinceEpoch(blockStart)) .inDays; if (lastBlockDuration >= 2 && lastBlockDuration <= 30) { tripLocations.add( TripMemory( List.from(currentBlockFiles), location, blockStart, lastTime, ), ); } } // Check if any trip locations should be merged final List mergedTrips = []; for (final trip in tripLocations) { final tripFirstTime = DateTime.fromMicrosecondsSinceEpoch( trip.firstCreationTime, ); final tripLastTime = DateTime.fromMicrosecondsSinceEpoch( trip.lastCreationTime, ); bool merged = false; for (int idx = 0; idx < mergedTrips.length; idx++) { final otherTrip = mergedTrips[idx]; final otherTripFirstTime = DateTime.fromMicrosecondsSinceEpoch(otherTrip.firstCreationTime); final otherTripLastTime = DateTime.fromMicrosecondsSinceEpoch(otherTrip.lastCreationTime); if (tripFirstTime .isBefore(otherTripLastTime.add(const Duration(days: 3))) && tripLastTime.isAfter( otherTripFirstTime.subtract(const Duration(days: 3)), )) { mergedTrips[idx] = TripMemory( otherTrip.files + trip.files, otherTrip.location, min(otherTrip.firstCreationTime, trip.firstCreationTime), max(otherTrip.lastCreationTime, trip.lastCreationTime), ); _logger.finest('Merged two trip locations'); merged = true; break; } } if (merged) continue; mergedTrips.add( TripMemory( trip.files, trip.location, trip.firstCreationTime, trip.lastCreationTime, ), ); } // Remove too small and too recent trips final List validTrips = []; for (final trip in mergedTrips) { if (trip.files.length >= 20 && trip.averageCreationTime < cutOffTime.microsecondsSinceEpoch) { validTrips.add(trip); } } // For now for testing let's just surface all base locations for (final baseLocation in baseLocations) { String name = "Base (${baseLocation.isCurrentBase ? 'current' : 'old'})"; final String? locationName = await _tryFindLocationName( baseLocation.files, base: true, ); if (locationName != null) { name = "$locationName (Base, ${baseLocation.isCurrentBase ? 'current' : 'old'})"; } searchResults.add( GenericSearchResult( ResultType.event, name, baseLocation.files, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: name, occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(baseLocation.files), filterIcon: Icons.event_outlined, ), ), ); } // For now we surface the two most recent trips of current month, and if none, the earliest upcoming redundant trip // Group the trips per month and then year final Map>> tripsByMonthYear = {}; for (final trip in validTrips) { final tripDate = DateTime.fromMicrosecondsSinceEpoch(trip.averageCreationTime); tripsByMonthYear .putIfAbsent(tripDate.month, () => {}) .putIfAbsent(tripDate.year, () => []) .add(trip); } // Flatten trips for the current month and annotate with their average date. final List currentMonthTrips = []; if (tripsByMonthYear.containsKey(currentMonth)) { for (final trips in tripsByMonthYear[currentMonth]!.values) { for (final trip in trips) { currentMonthTrips.add(trip); } } } // If there are past trips this month, show the one or two most recent ones. if (currentMonthTrips.isNotEmpty) { currentMonthTrips.sort( (a, b) => b.averageCreationTime.compareTo(a.averageCreationTime), ); final tripsToShow = currentMonthTrips.take(2); for (final trip in tripsToShow) { final year = DateTime.fromMicrosecondsSinceEpoch(trip.averageCreationTime).year; final String? locationName = await _tryFindLocationName(trip.files); String name = "Trip in $year"; if (locationName != null) { name = "Trip to $locationName"; } else if (year == currentTime.year - 1) { name = "Last year's trip"; } final photoSelection = await _bestSelection(trip.files); searchResults.add( GenericSearchResult( ResultType.event, name, photoSelection, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: name, occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(photoSelection), filterIcon: Icons.event_outlined, ), ), ); if (limit != null && searchResults.length >= limit) { return searchResults; } } } // Otherwise, if no trips happened in the current month, // look for the earliest upcoming trip in another month that has 3+ trips. else { // TODO lau: make sure the same upcoming trip isn't shown multiple times over multiple months final sortedUpcomingMonths = List.generate(12, (i) => ((currentMonth + i) % 12) + 1); checkUpcomingMonths: for (final month in sortedUpcomingMonths) { if (tripsByMonthYear.containsKey(month)) { final List thatMonthTrips = []; for (final trips in tripsByMonthYear[month]!.values) { for (final trip in trips) { thatMonthTrips.add(trip); } } if (thatMonthTrips.length >= 3) { // take and use the third earliest trip thatMonthTrips.sort( (a, b) => a.averageCreationTime.compareTo(b.averageCreationTime), ); final trip = thatMonthTrips[2]; final year = DateTime.fromMicrosecondsSinceEpoch(trip.averageCreationTime) .year; final String? locationName = await _tryFindLocationName(trip.files); String name = "Trip in $year"; if (locationName != null) { name = "Trip to $locationName"; } else if (year == currentTime.year - 1) { name = "Last year's trip"; } final photoSelection = await _bestSelection(trip.files); searchResults.add( GenericSearchResult( ResultType.event, name, photoSelection, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: name, occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(photoSelection), filterIcon: Icons.event_outlined, ), ), ); break checkUpcomingMonths; } } } } return searchResults; } Future> onThisDayOrWeekResults( BuildContext context, int? limit, ) async { final List searchResults = []; final trips = await getTripsResults(context, limit); if (trips.isNotEmpty) { searchResults.addAll(trips); } final allFiles = await getAllFilesForSearch(); if (allFiles.isEmpty) return []; final currentTime = DateTime.now().toLocal(); final currentDayMonth = currentTime.month * 100 + currentTime.day; final currentWeek = _getWeekNumber(currentTime); final currentMonth = currentTime.month; final cutOffTime = currentTime.subtract(const Duration(days: 365)); final averageDailyPhotos = allFiles.length / 365; final significantDayThreshold = averageDailyPhotos * 0.25; final significantWeekThreshold = averageDailyPhotos * 0.40; // Group files by day-month and year final dayMonthYearGroups = >>{}; for (final file in allFiles) { if (file.creationTime! > cutOffTime.microsecondsSinceEpoch) continue; final creationTime = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); final dayMonth = creationTime.month * 100 + creationTime.day; final year = creationTime.year; dayMonthYearGroups .putIfAbsent(dayMonth, () => {}) .putIfAbsent(year, () => []) .add(file); } // Process each nearby day-month to find significant days for (final dayMonth in dayMonthYearGroups.keys) { final dayDiff = dayMonth - currentDayMonth; if (dayDiff < 0 || dayDiff > 2) continue; // TODO: lau: this doesn't cover month changes properly final yearGroups = dayMonthYearGroups[dayMonth]!; final significantDays = yearGroups.entries .where((e) => e.value.length > significantDayThreshold) .map((e) => e.key) .toList(); if (significantDays.length >= 3) { // Combine all years for this day-month final date = DateTime(currentTime.year, dayMonth ~/ 100, dayMonth % 100); final allPhotos = yearGroups.values.expand((x) => x).toList(); final photoSelection = await _bestSelection(allPhotos); searchResults.add( GenericSearchResult( ResultType.event, "${DateFormat('MMMM d').format(date)} through the years", photoSelection, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: DateFormat('MMMM d').format(date), occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(photoSelection), filterIcon: Icons.event_outlined, ), ), ); } else { // Individual entries for significant years for (final year in significantDays) { final date = DateTime(year, dayMonth ~/ 100, dayMonth % 100); final files = yearGroups[year]!; final photoSelection = await _bestSelection(files); String name = DateFormat.yMMMd(Localizations.localeOf(context).languageCode) .format(date); if (date.day == currentTime.day && date.month == currentTime.month) { name = "This day, ${currentTime.year - date.year} years back"; } searchResults.add( GenericSearchResult( ResultType.event, name, photoSelection, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: name, occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(photoSelection), filterIcon: Icons.event_outlined, ), ), ); } } if (limit != null && searchResults.length >= limit) return searchResults; } // process to find significant weeks (only if there are no significant days) if (searchResults.isEmpty) { // Group files by week and year final currentWeekYearGroups = >{}; for (final file in allFiles) { if (file.creationTime! > cutOffTime.microsecondsSinceEpoch) continue; final creationTime = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); final week = _getWeekNumber(creationTime); if (week != currentWeek) continue; final year = creationTime.year; currentWeekYearGroups.putIfAbsent(year, () => []).add(file); } // Process the week and see if it's significant if (currentWeekYearGroups.isNotEmpty) { final significantWeeks = currentWeekYearGroups.entries .where((e) => e.value.length > significantWeekThreshold) .map((e) => e.key) .toList(); if (significantWeeks.length >= 3) { // Combine all years for this week final allPhotos = currentWeekYearGroups.values.expand((x) => x).toList(); final photoSelection = await _bestSelection(allPhotos); searchResults.add( GenericSearchResult( ResultType.event, "This week through the years", photoSelection, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: "Week $currentWeek", occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(photoSelection), filterIcon: Icons.event_outlined, ), ), ); } else { // Individual entries for significant years for (final year in significantWeeks) { final date = DateTime(year, 1, 1).add( Duration(days: (currentWeek - 1) * 7), ); final files = currentWeekYearGroups[year]!; final photoSelection = await _bestSelection(files); final name = "This week, ${currentTime.year - date.year} years back"; searchResults.add( GenericSearchResult( ResultType.event, name, photoSelection, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: name, occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(photoSelection), filterIcon: Icons.event_outlined, ), ), ); } } } } if (limit != null && searchResults.length >= limit) return searchResults; // process to find fillers (months) const wantedMemories = 3; final neededMemories = wantedMemories - searchResults.length; if (neededMemories <= 0) return searchResults; const monthSelectionSize = 20; // Group files by month and year final currentMonthYearGroups = >{}; for (final file in allFiles) { if (file.creationTime! > cutOffTime.microsecondsSinceEpoch) continue; final creationTime = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); final month = creationTime.month; if (month != currentMonth) continue; final year = creationTime.year; currentMonthYearGroups.putIfAbsent(year, () => []).add(file); } // Add the largest two months plus the month through the years final sortedYearsForCurrentMonth = currentMonthYearGroups.keys.toList() ..sort( (a, b) => currentMonthYearGroups[b]!.length.compareTo( currentMonthYearGroups[a]!.length, ), ); if (neededMemories > 1) { for (int i = neededMemories; i > 1; i--) { if (sortedYearsForCurrentMonth.isEmpty) break; final year = sortedYearsForCurrentMonth.removeAt(0); final monthYearFiles = currentMonthYearGroups[year]!; final photoSelection = await _bestSelection( monthYearFiles, prefferedSize: monthSelectionSize, ); final monthName = DateFormat.MMMM(Localizations.localeOf(context).languageCode) .format(DateTime(year, currentMonth)); final name = monthName + ", ${currentTime.year - year} years back"; searchResults.add( GenericSearchResult( ResultType.event, name, photoSelection, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: name, occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(photoSelection), filterIcon: Icons.event_outlined, ), ), ); } } // Show the month through the remaining years if (sortedYearsForCurrentMonth.isEmpty) return searchResults; final allPhotos = sortedYearsForCurrentMonth .expand((year) => currentMonthYearGroups[year]!) .toList(); final photoSelection = await _bestSelection(allPhotos, prefferedSize: monthSelectionSize); final monthName = DateFormat.MMMM(Localizations.localeOf(context).languageCode) .format(DateTime(currentTime.year, currentMonth)); final name = monthName + " through the years"; searchResults.add( GenericSearchResult( ResultType.event, name, photoSelection, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: name, occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(photoSelection), filterIcon: Icons.event_outlined, ), ), ); return searchResults; } int _getWeekNumber(DateTime date) { // Get day of year (1-366) final int dayOfYear = int.parse(DateFormat('D').format(date)); // Integer division by 7 and add 1 to start from week 1 return ((dayOfYear - 1) ~/ 7) + 1; } Future _tryFindLocationName( List files, { bool base = false, }) async { final results = await locationService.getFilesInCity(files, ''); final List sortedByResultCount = results.keys.toList() ..sort((a, b) => results[b]!.length.compareTo(results[a]!.length)); if (sortedByResultCount.isEmpty) return null; final biggestPlace = sortedByResultCount.first; if (results[biggestPlace]!.length > files.length / 2) { return biggestPlace.city; } if (results.length > 2 && results.keys.map((city) => city.country).toSet().length == 1 && !base) { return biggestPlace.country; } return null; } /// Returns the best selection of files from the given list. /// Makes sure that the selection is not more than [prefferedSize] or 10 files, /// and that each year of the original list is represented. Future> _bestSelection( List files, { int? prefferedSize, }) async { final fileCount = files.length; int targetSize = prefferedSize ?? 10; if (fileCount <= targetSize) return files; final safeFiles = files.where((file) => file.uploadedFileID != null).toList(); final safeCount = safeFiles.length; final fileIDs = safeFiles.map((e) => e.uploadedFileID!).toSet(); final fileIdToFace = await MLDataDB.instance.getFacesForFileIDs(fileIDs); final faceIDs = fileIdToFace.values.expand((x) => x.map((face) => face.faceID)).toSet(); final faceIDsToPersonID = await MLDataDB.instance.getFaceIdToPersonIdForFaces(faceIDs); final fileIdToClip = await MLDataDB.instance.getClipVectorsForFileIDs(fileIDs); final allYears = safeFiles.map((e) { final creationTime = DateTime.fromMicrosecondsSinceEpoch(e.creationTime!); return creationTime.year; }).toSet(); // Get clip scores for each file const query = 'Photo of a precious memory radiating warmth, vibrant energy, or quiet beauty — alive with color, light, or emotion'; // TODO: lau: optimize this later so we don't keep computing embedding final textEmbedding = await MLComputer.instance.runClipText(query); final textVector = Vector.fromList(textEmbedding); const clipThreshold = 0.75; final fileToScore = {}; for (final file in safeFiles) { final clip = fileIdToClip[file.uploadedFileID!]; if (clip == null) { fileToScore[file.uploadedFileID!] = 0; continue; } final score = clip.vector.dot(textVector); fileToScore[file.uploadedFileID!] = score; } // Get face scores for each file final fileToFaceCount = {}; for (final file in safeFiles) { final fileID = file.uploadedFileID!; fileToFaceCount[fileID] = 0; final faces = fileIdToFace[fileID]; if (faces == null || faces.isEmpty) { continue; } for (final face in faces) { if (faceIDsToPersonID.containsKey(face.faceID)) { fileToFaceCount[fileID] = fileToFaceCount[fileID]! + 10; } else { fileToFaceCount[fileID] = fileToFaceCount[fileID]! + 1; } } } final filteredFiles = []; if (allYears.length <= 1) { // TODO: lau: eventually this sorting might have to be replaced with some scoring system // sort first on clip embeddings score (descending) safeFiles.sort( (a, b) => fileToScore[b.uploadedFileID!]! .compareTo(fileToScore[a.uploadedFileID!]!), ); // then sort on faces (descending), heavily prioritizing named faces safeFiles.sort( (a, b) => fileToFaceCount[b.uploadedFileID!]! .compareTo(fileToFaceCount[a.uploadedFileID!]!), ); // then filter out similar images as much as possible filteredFiles.add(safeFiles.first); int skipped = 0; filesLoop: for (final file in safeFiles.sublist(1)) { if (filteredFiles.length >= targetSize) break; final clip = fileIdToClip[file.uploadedFileID!]; if (clip != null && (safeCount - skipped) > targetSize) { for (final filteredFile in filteredFiles) { final fClip = fileIdToClip[filteredFile.uploadedFileID!]; if (fClip == null) continue; final similarity = clip.vector.dot(fClip.vector); if (similarity > clipThreshold) { skipped++; continue filesLoop; } } } filteredFiles.add(file); } } else { // Multiple years, each represented and roughly equally distributed if (prefferedSize == null && (allYears.length * 2) > 10) { targetSize = allYears.length * 3; if (safeCount < targetSize) return safeFiles; } // Group files by year and sort each year's list by CLIP then face count final yearToFiles = >{}; for (final file in safeFiles) { final creationTime = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!); final year = creationTime.year; yearToFiles.putIfAbsent(year, () => []).add(file); } for (final year in yearToFiles.keys) { final yearFiles = yearToFiles[year]!; // sort first on clip embeddings score (descending) yearFiles.sort( (a, b) => fileToScore[b.uploadedFileID!]! .compareTo(fileToScore[a.uploadedFileID!]!), ); // then sort on faces (descending), heavily prioritizing named faces yearFiles.sort( (a, b) => fileToFaceCount[b.uploadedFileID!]! .compareTo(fileToFaceCount[a.uploadedFileID!]!), ); } // Then join the years together one by one and filter similar images final years = yearToFiles.keys.toList() ..sort((a, b) => b.compareTo(a)); // Recent years first int round = 0; int skipped = 0; whileLoop: while (filteredFiles.length + skipped < safeCount) { yearLoop: for (final year in years) { final yearFiles = yearToFiles[year]!; if (yearFiles.isEmpty) continue; final newFile = yearFiles.removeAt(0); if (round != 0 && (safeCount - skipped) > targetSize) { // check for filtering final clip = fileIdToClip[newFile.uploadedFileID!]; if (clip != null) { for (final filteredFile in filteredFiles) { final fClip = fileIdToClip[filteredFile.uploadedFileID!]; if (fClip == null) continue; final similarity = clip.vector.dot(fClip.vector); if (similarity > clipThreshold) { skipped++; continue yearLoop; } } } } filteredFiles.add(newFile); if (filteredFiles.length >= targetSize || filteredFiles.length + skipped >= safeCount) { break whileLoop; } } round++; // Extra safety to prevent infinite loops if (round > safeCount) break; } } // Order the final selection chronologically filteredFiles.sort((a, b) => b.creationTime!.compareTo(a.creationTime!)); return filteredFiles; } Future getRandomDateResults( BuildContext context, ) async { final allFiles = await getAllFilesForSearch(); 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', ); final name = DateFormat.yMMMd(Localizations.localeOf(context).languageCode) .format(originalDateTime.toLocal()); return GenericSearchResult( ResultType.event, name, matchedFiles, hierarchicalSearchFilter: TopLevelGenericFilter( filterName: name, occurrence: kMostRelevantFilter, filterResultType: ResultType.event, matchedUploadedIDs: filesToUploadedFileIDs(matchedFiles), filterIcon: Icons.event_outlined, ), ); } Future> getContactSearchResults( String query, ) async { final lowerCaseQuery = query.toLowerCase(); final searchResults = []; final allFiles = await getAllFilesForSearch(); final peopleToSharedFiles = >{}; final existingEmails = {}; for (EnteFile file in allFiles) { if (file.isOwner) continue; final fileOwner = CollectionsService.instance .getFileOwner(file.ownerID!, file.collectionID); if (fileOwner.email.toLowerCase().contains(lowerCaseQuery) || ((fileOwner.displayName?.toLowerCase().contains(lowerCaseQuery)) ?? false)) { if (peopleToSharedFiles.containsKey(fileOwner)) { peopleToSharedFiles[fileOwner]!.add(file); } else { peopleToSharedFiles[fileOwner] = [file]; existingEmails.add(fileOwner.email); } } } final relevantContactEmails = UserService.instance.getEmailIDsOfRelevantContacts(); for (final email in relevantContactEmails.difference(existingEmails)) { final user = User(email: email); if (user.email.toLowerCase().contains(lowerCaseQuery) || ((user.displayName?.toLowerCase().contains(lowerCaseQuery)) ?? false)) { peopleToSharedFiles[user] = []; } } peopleToSharedFiles.forEach((key, value) { searchResults.add( GenericSearchResult( ResultType.shared, key.displayName != null && key.displayName!.isNotEmpty ? key.displayName! : key.email, value, hierarchicalSearchFilter: ContactsFilter( user: key, occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(value), ), params: { kPersonParamID: key.linkedPersonID, kContactEmail: key.email, }, ), ); }); return searchResults; } Future> getAllContactsSearchResults( int? limit, ) async { try { final searchResults = []; final allFiles = await getAllFilesForSearch(); final peopleToSharedFiles = >{}; final existingEmails = {}; 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]; existingEmails.add(fileOwner.email); peopleCount++; } } final allRelevantEmails = UserService.instance.getEmailIDsOfRelevantContacts(); int? remainingLimit = limit != null ? limit - peopleCount : null; if (remainingLimit != null) { // limit - peopleCount will never be negative as of writing this. // Just in case if something changes in future, we are handling it here. remainingLimit = max(remainingLimit, 0); } final emailsWithNoSharedFiles = allRelevantEmails.difference(existingEmails); if (remainingLimit == null) { for (final email in emailsWithNoSharedFiles) { final user = User(email: email); peopleToSharedFiles[user] = []; } } else { for (final email in emailsWithNoSharedFiles) { if (remainingLimit == 0) break; final user = User(email: email); peopleToSharedFiles[user] = []; remainingLimit = remainingLimit! - 1; } } peopleToSharedFiles.forEach((key, value) { final name = key.displayName != null && key.displayName!.isNotEmpty ? key.displayName! : key.email; searchResults.add( GenericSearchResult( ResultType.shared, name, value, hierarchicalSearchFilter: ContactsFilter( user: key, occurrence: kMostRelevantFilter, matchedUploadedIDs: filesToUploadedFileIDs(value), ), params: { kPersonParamID: key.linkedPersonID, kContactEmail: key.email, }, ), ); }); 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; } }