diff --git a/app/lib/app_init.dart b/app/lib/app_init.dart index e960f092..08af21b5 100644 --- a/app/lib/app_init.dart +++ b/app/lib/app_init.dart @@ -212,7 +212,7 @@ Future _initDiContainer(InitIsolateType isolateType) async { c.tagRepoRemote = const TagRepo(TagRemoteDataSource()); c.tagRepoLocal = TagRepo(TagSqliteDbDataSource(c.sqliteDb)); c.taggedFileRepo = const TaggedFileRepo(TaggedFileRemoteDataSource()); - c.searchRepo = SearchRepo(SearchSqliteDbDataSource(c.sqliteDb)); + c.searchRepo = SearchRepo(SearchSqliteDbDataSource(c)); if (platform_k.isAndroid) { // local file currently only supported on Android diff --git a/app/lib/bloc/search.dart b/app/lib/bloc/search.dart index 42f2ebe8..b2375167 100644 --- a/app/lib/bloc/search.dart +++ b/app/lib/bloc/search.dart @@ -62,7 +62,7 @@ abstract class SearchBlocState { } class SearchBlocInit extends SearchBlocState { - SearchBlocInit() : super(null, const SearchCriteria({}, []), const []); + SearchBlocInit() : super(null, const SearchCriteria("", []), const []); } class SearchBlocLoading extends SearchBlocState { diff --git a/app/lib/entity/search.dart b/app/lib/entity/search.dart index 3910c02b..8ed66967 100644 --- a/app/lib/entity/search.dart +++ b/app/lib/entity/search.dart @@ -1,33 +1,34 @@ import 'package:nc_photos/account.dart'; -import 'package:nc_photos/ci_string.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/iterable_extension.dart'; class SearchCriteria { - const SearchCriteria(this.keywords, this.filters); + const SearchCriteria(this.input, this.filters); SearchCriteria copyWith({ - Set? keywords, + String? input, List? filters, }) => SearchCriteria( - keywords ?? Set.of(this.keywords), + input ?? this.input, filters ?? List.of(this.filters), ); @override toString() => "$runtimeType {" - "keywords: ${keywords.toReadableString()}, " + "input: $input, " "filters: ${filters.toReadableString()}, " "}"; - final Set keywords; + final String input; final List filters; } abstract class SearchFilter { void apply(sql.FilesQueryBuilder query); + bool isSatisfy(File file); } enum SearchFileType { @@ -55,6 +56,17 @@ class SearchFileTypeFilter implements SearchFilter { query.byMimePattern(type.toSqlPattern()); } + @override + isSatisfy(File file) { + switch (type) { + case SearchFileType.image: + return file_util.isSupportedImageFormat(file); + + case SearchFileType.video: + return file_util.isSupportedVideoFormat(file); + } + } + @override toString() => "$runtimeType {" "type: ${type.name}, " @@ -71,6 +83,9 @@ class SearchFavoriteFilter implements SearchFilter { query.byFavorite(value); } + @override + isSatisfy(File file) => (file.isFavorite ?? false) == value; + @override toString() => "$runtimeType {" "value: $value, " diff --git a/app/lib/entity/search/data_source.dart b/app/lib/entity/search/data_source.dart index 04ddf001..8967bb5b 100644 --- a/app/lib/entity/search/data_source.dart +++ b/app/lib/entity/search/data_source.dart @@ -1,53 +1,186 @@ import 'package:drift/drift.dart' as sql; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; +import 'package:nc_photos/ci_string.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/search.dart'; -import 'package:nc_photos/entity/sqlite_table.dart' as sql; +import 'package:nc_photos/entity/search_util.dart' as search_util; +import 'package:nc_photos/entity/sqlite_table_converter.dart'; import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; +import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/use_case/list_tagged_file.dart'; +import 'package:nc_photos/use_case/populate_person.dart'; class SearchSqliteDbDataSource implements SearchDataSource { - SearchSqliteDbDataSource(this.sqliteDb); + SearchSqliteDbDataSource(this._c); @override list(Account account, SearchCriteria criteria) async { _log.info("[list] $criteria"); - final keywords = - criteria.keywords.map((e) => e.toCaseInsensitiveString()).toList(); - final dbFiles = await sqliteDb.use((db) async { - final query = db.queryFiles().run((q) { - q.setQueryMode(sql.FilesQueryMode.completeFile); - q.setAppAccount(account); - for (final r in account.roots) { - if (r.isNotEmpty) { - q.byOrRelativePathPattern("$r/%"); - } - } - for (final f in criteria.filters) { - f.apply(q); - } - return q.build(); - }); - // limit to supported formats only - query.where(db.files.contentType.like("image/%") | - db.files.contentType.like("video/%")); - for (final k in keywords) { - query.where(db.accountFiles.relativePath.like("%$k%")); - } - return await query - .map((r) => sql.CompleteFile( - r.readTable(db.files), - r.readTable(db.accountFiles), - r.readTableOrNull(db.images), - r.readTableOrNull(db.imageLocations), - r.readTableOrNull(db.trashes), - )) - .get(); - }); - return await dbFiles.convertToAppFile(account); + final stopwatch = Stopwatch()..start(); + try { + final keywords = search_util + .cleanUpSymbols(criteria.input) + .split(" ") + .where((s) => s.isNotEmpty) + .map((s) => s.toCi().toCaseInsensitiveString()) + .toSet(); + final futures = await Future.wait([ + _listByPath(account, criteria, keywords), + _listByLocation(account, criteria), + _listByTag(account, criteria), + _listByPerson(account, criteria), + ]); + return futures.flatten().distinctIf( + (a, b) => a.compareServerIdentity(b), + (a) => a.identityHashCode, + ); + } finally { + _log.info("[list] Elapsed time: ${stopwatch.elapsedMilliseconds}ms"); + } } - final sql.SqliteDb sqliteDb; + Future> _listByPath( + Account account, SearchCriteria criteria, Set keywords) async { + try { + final dbFiles = await _c.sqliteDb.use((db) async { + final query = db.queryFiles().run((q) { + q.setQueryMode(sql.FilesQueryMode.completeFile); + q.setAppAccount(account); + for (final r in account.roots) { + if (r.isNotEmpty) { + q.byOrRelativePathPattern("$r/%"); + } + } + for (final f in criteria.filters) { + f.apply(q); + } + return q.build(); + }); + // limit to supported formats only + query.where(db.files.contentType.like("image/%") | + db.files.contentType.like("video/%")); + for (final k in keywords) { + query.where(db.accountFiles.relativePath.like("%$k%")); + } + return await query + .map((r) => sql.CompleteFile( + r.readTable(db.files), + r.readTable(db.accountFiles), + r.readTableOrNull(db.images), + r.readTableOrNull(db.imageLocations), + r.readTableOrNull(db.trashes), + )) + .get(); + }); + return await dbFiles.convertToAppFile(account); + } catch (e, stackTrace) { + _log.severe("[_listByPath] Failed while _listByPath", e, stackTrace); + return []; + } + } + + Future> _listByLocation( + Account account, SearchCriteria criteria) async { + // location search requires exact match, for example, searching "united" + // will NOT return results from US, UK, UAE, etc. Searching by the alpha2 + // code is supported + try { + final dbFiles = await _c.sqliteDb.use((db) async { + final query = db.queryFiles().run((q) { + q.setQueryMode(sql.FilesQueryMode.completeFile); + q.setAppAccount(account); + for (final r in account.roots) { + if (r.isNotEmpty) { + q.byOrRelativePathPattern("$r/%"); + } + } + for (final f in criteria.filters) { + f.apply(q); + } + q.byLocation(criteria.input); + return q.build(); + }); + // limit to supported formats only + query.where(db.files.contentType.like("image/%") | + db.files.contentType.like("video/%")); + return await query + .map((r) => sql.CompleteFile( + r.readTable(db.files), + r.readTable(db.accountFiles), + r.readTableOrNull(db.images), + r.readTableOrNull(db.imageLocations), + r.readTableOrNull(db.trashes), + )) + .get(); + }); + return await dbFiles.convertToAppFile(account); + } catch (e, stackTrace) { + _log.severe( + "[_listByLocation] Failed while _listByLocation", e, stackTrace); + return []; + } + } + + Future> _listByTag( + Account account, SearchCriteria criteria) async { + // tag search requires exact match, for example, searching "super" will NOT + // return results from "super tag" + try { + final dbTag = await _c.sqliteDb.use((db) async { + return await db.tagByDisplayName( + appAccount: account, + displayName: criteria.input, + ); + }); + if (dbTag == null) { + return []; + } + final tag = SqliteTagConverter.fromSql(dbTag); + _log.info("[_listByTag] Found tag: ${tag.displayName}"); + final files = await ListTaggedFile(_c)(account, [tag]); + return files + .where((f) => criteria.filters.every((c) => c.isSatisfy(f))) + .toList(); + } catch (e, stackTrace) { + _log.severe("[_listByTag] Failed while _listByTag", e, stackTrace); + return []; + } + } + + Future> _listByPerson( + Account account, SearchCriteria criteria) async { + // person search requires exact match of any parts, for example, searching + // "Ada" will return results from "Ada Crook" but NOT "Adabelle" + try { + final dbPersons = await _c.sqliteDb.use((db) async { + return await db.personsByName( + appAccount: account, + name: criteria.input, + ); + }); + if (dbPersons.isEmpty) { + return []; + } + final persons = await dbPersons.convertToAppPerson(); + _log.info( + "[_listByPerson] Found people: ${persons.map((p) => p.name).toReadableString()}"); + final futures = await Future.wait( + persons.map((p) async => await _c.faceRepo.list(account, p))); + final faces = futures.flatten().toList(); + final files = await PopulatePerson(_c)(account, faces); + return files + .where((f) => criteria.filters.every((c) => c.isSatisfy(f))) + .toList(); + } catch (e, stackTrace) { + _log.severe("[_listByPerson] Failed while _listByPerson", e, stackTrace); + return []; + } + } + + final DiContainer _c; static final _log = Logger("entity.search.data_source.SearchSqliteDbDataSource"); diff --git a/app/lib/entity/sqlite_table_extension.dart b/app/lib/entity/sqlite_table_extension.dart index 120047bb..118f3521 100644 --- a/app/lib/entity/sqlite_table_extension.dart +++ b/app/lib/entity/sqlite_table_extension.dart @@ -1,6 +1,7 @@ import 'package:drift/drift.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart' as app; +import 'package:nc_photos/ci_string.dart'; import 'package:nc_photos/entity/file.dart' as app; import 'package:nc_photos/entity/sqlite_table.dart'; import 'package:nc_photos/entity/sqlite_table_converter.dart'; @@ -8,6 +9,7 @@ import 'package:nc_photos/entity/sqlite_table_isolate.dart'; import 'package:nc_photos/future_extension.dart'; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/location_util.dart' as location_util; import 'package:nc_photos/mobile/platform.dart' if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform; import 'package:nc_photos/object_extension.dart'; @@ -430,6 +432,30 @@ extension SqliteDbExtension on SqliteDb { } } + Future tagByDisplayName({ + Account? sqlAccount, + app.Account? appAccount, + required String displayName, + }) { + assert((sqlAccount != null) != (appAccount != null)); + if (sqlAccount != null) { + final query = select(tags) + ..where((t) => t.server.equals(sqlAccount.server)) + ..where((t) => t.displayName.like(displayName)) + ..limit(1); + return query.getSingleOrNull(); + } else { + final query = select(tags).join([ + innerJoin(servers, servers.rowId.equalsExp(tags.server), + useColumns: false), + ]) + ..where(servers.address.equals(appAccount!.url)) + ..where(tags.displayName.like(displayName)) + ..limit(1); + return query.map((r) => r.readTable(tags)).getSingleOrNull(); + } + } + Future> allPersons({ Account? sqlAccount, app.Account? appAccount, @@ -453,6 +479,35 @@ extension SqliteDbExtension on SqliteDb { } } + Future> personsByName({ + Account? sqlAccount, + app.Account? appAccount, + required String name, + }) { + assert((sqlAccount != null) != (appAccount != null)); + if (sqlAccount != null) { + final query = select(persons) + ..where((t) => t.account.equals(sqlAccount.rowId)) + ..where((t) => + t.name.like(name) | + t.name.like("% $name") | + t.name.like("$name %")); + return query.get(); + } else { + final query = select(persons).join([ + innerJoin(accounts, accounts.rowId.equalsExp(persons.account), + useColumns: false), + innerJoin(servers, servers.rowId.equalsExp(accounts.server), + useColumns: false), + ]) + ..where(servers.address.equals(appAccount!.url)) + ..where(persons.name.like(name) | + persons.name.like("% $name") | + persons.name.like("$name %")); + return query.map((r) => r.readTable(persons)).get(); + } + } + Future truncate() async { await delete(servers).go(); // technically deleting Servers table is enough to clear the followings, but @@ -555,6 +610,10 @@ class FilesQueryBuilder { _byServerRowId = serverRowId; } + void byLocation(String location) { + _byLocation = location; + } + JoinedSelectStatement build() { if (_sqlAccount == null && _appAccount == null && !_isAccountless) { throw StateError("Invalid query: missing account"); @@ -638,6 +697,20 @@ class FilesQueryBuilder { if (_byServerRowId != null) { query.where(db.files.server.equals(_byServerRowId)); } + if (_byLocation != null) { + var clause = db.imageLocations.name.like(_byLocation!) | + db.imageLocations.admin1.like(_byLocation!) | + db.imageLocations.admin2.like(_byLocation!); + final countryCode = location_util.nameToAlpha2Code(_byLocation!.toCi()); + if (countryCode != null) { + clause = clause | db.imageLocations.countryCode.equals(countryCode); + } else if (_byLocation!.length == 2 && + location_util.alpha2CodeToName(_byLocation!.toUpperCase()) != null) { + clause = clause | + db.imageLocations.countryCode.equals(_byLocation!.toUpperCase()); + } + query.where(clause); + } return query; } @@ -659,6 +732,7 @@ class FilesQueryBuilder { bool? _byFavorite; int? _byDirRowId; int? _byServerRowId; + String? _byLocation; } app.File _covertSqliteDbFile(Map map) { diff --git a/app/lib/location_util.dart b/app/lib/location_util.dart index 93688133..d1f52f39 100644 --- a/app/lib/location_util.dart +++ b/app/lib/location_util.dart @@ -1,7 +1,25 @@ -/// Convert a ISO 3166-1 alpha-2 code into country name -String? alpha2CodeToName(String cc) => _ccMap[cc]; +import 'package:nc_photos/ci_string.dart'; -const _ccMap = { +/// Convert a ISO 3166-1 alpha-2 code into country name +String? alpha2CodeToName(String cc) => _ccMap.byCc(cc); + +/// Convert a country name into ISO 3166-1 alpha-2 code +String? nameToAlpha2Code(CiString name) => _ccMap.byCiName(name); + +class _CcMap { + _CcMap(this._ccMap) + : _nameMap = _ccMap.map( + (cc, name) => MapEntry(name.toCi().toCaseInsensitiveString(), cc)); + + String? byCc(String cc) => _ccMap[cc]; + String? byCiName(CiString name) => + _nameMap[name.toCaseInsensitiveString()]; + + final Map _ccMap; + final Map _nameMap; +} + +final _ccMap = _CcMap({ "AD": "Andorra", "AE": "United Arab Emirates", "AF": "Afghanistan", @@ -251,4 +269,4 @@ const _ccMap = { "ZA": "South Africa", "ZM": "Zambia", "ZW": "Zimbabwe", -}; +}); diff --git a/app/lib/widget/home_search.dart b/app/lib/widget/home_search.dart index ad15cef5..f4ba5afa 100644 --- a/app/lib/widget/home_search.dart +++ b/app/lib/widget/home_search.dart @@ -8,13 +8,11 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/search.dart'; -import 'package:nc_photos/ci_string.dart'; import 'package:nc_photos/compute_queue.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/download_handler.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/search.dart'; -import 'package:nc_photos/entity/search_util.dart' as search_util; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/language_util.dart' as language_util; @@ -536,14 +534,7 @@ class _HomeSearchState extends State } void _reqQuery(String input, List filters) { - final keywords = search_util - .cleanUpSymbols(input) - .split(" ") - .where((s) => s.isNotEmpty) - .map((s) => s.toCi()) - .toSet(); - _bloc.add( - SearchBlocQuery(widget.account, SearchCriteria(keywords, filters))); + _bloc.add(SearchBlocQuery(widget.account, SearchCriteria(input, filters))); } void _reqResetLanding() {