Search now return results from tags, locations and people

This commit is contained in:
Ming Ming 2022-09-08 01:01:50 +08:00
parent a3c98267eb
commit 9fd57951ed
7 changed files with 289 additions and 58 deletions

View file

@ -212,7 +212,7 @@ Future<void> _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

View file

@ -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 {

View file

@ -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<CiString>? keywords,
String? input,
List<SearchFilter>? 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<CiString> keywords;
final String input;
final List<SearchFilter> 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, "

View file

@ -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<List<File>> _listByPath(
Account account, SearchCriteria criteria, Set<String> 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<List<File>> _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<List<File>> _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<List<File>> _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");

View file

@ -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<Tag?> 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<List<Person>> allPersons({
Account? sqlAccount,
app.Account? appAccount,
@ -453,6 +479,35 @@ extension SqliteDbExtension on SqliteDb {
}
}
Future<List<Person>> 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<void> 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) {

View file

@ -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<String, String> _ccMap;
final Map<String, String> _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",
};
});

View file

@ -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<HomeSearch>
}
void _reqQuery(String input, List<SearchFilter> 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() {