mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-02-24 02:18:50 +01:00
Search now return results from tags, locations and people
This commit is contained in:
parent
a3c98267eb
commit
9fd57951ed
7 changed files with 289 additions and 58 deletions
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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, "
|
||||
|
|
|
@ -1,20 +1,51 @@
|
|||
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 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");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
@ -45,9 +76,111 @@ class SearchSqliteDbDataSource implements SearchDataSource {
|
|||
.get();
|
||||
});
|
||||
return await dbFiles.convertToAppFile(account);
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe("[_listByPath] Failed while _listByPath", e, stackTrace);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
final sql.SqliteDb sqliteDb;
|
||||
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");
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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",
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
|
|
Loading…
Reference in a new issue