import 'dart:io' as io; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_collection/np_collection.dart'; import 'package:np_common/object_util.dart'; import 'package:np_common/or_null.dart'; import 'package:np_common/type.dart'; import 'package:np_datetime/np_datetime.dart'; import 'package:np_db/np_db.dart'; import 'package:np_db_sqlite/src/converter.dart'; import 'package:np_db_sqlite/src/database.dart'; import 'package:np_db_sqlite/src/database_extension.dart'; import 'package:np_db_sqlite/src/isolate_util.dart'; import 'package:np_db_sqlite/src/table.dart'; import 'package:np_db_sqlite/src/util.dart'; import 'package:np_platform_util/np_platform_util.dart'; part 'sqlite_api.g.dart'; @npLog class NpDbSqlite implements NpDb { NpDbSqlite(); @override Future<void> initMainIsolate({ required int? androidSdk, }) async { initDrift(); if (getRawPlatform() == NpPlatform.android && androidSdk! < 24) { _log.info("[initMainIsolate] Workaround Android 6- bug"); // see: https://github.com/flutter/flutter/issues/73318 and // https://github.com/simolus3/drift/issues/895 await applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); } // use driftIsolate to prevent DB blocking the UI thread if (getRawPlatform() == NpPlatform.web) { // no isolate support on web _db = SqliteDb(); } else { _db = await createDb(); } } @override Future<void> initBackgroundIsolate({ required int? androidSdk, }) async { initDrift(); // service already runs in an isolate _db = SqliteDb(); } @visibleForTesting Future<void> initWithDb({ required SqliteDb db, }) async { initDrift(); _db = db; } @override Future<void> dispose() { return _db.close(); } @override Future<io.File> export(io.Directory dir) => exportSqliteDb(_db, dir); @override Future<U> compute<T, U>(NpDbComputeCallback<T, U> callback, T args) { return _db.isolate(args, (db, message) async { final that = NpDbSqlite(); await that.initWithDb(db: db); return callback(that, message); }); } @override Future<void> addAccounts(List<DbAccount> accounts) { return _db.use((db) async { await db.insertAccounts(accounts); }); } @override Future<void> clearAndInitWithAccounts(List<DbAccount> accounts) { return _db.use((db) async { await db.truncate(); await db.insertAccounts(accounts); }); } @override Future<void> deleteAccount(DbAccount account) { return _db.use((db) async { await db.deleteAccount(account); }); } @override Future<List<DbAlbum>> getAlbumsByAlbumFileIds({ required DbAccount account, required List<int> fileIds, }) async { final sqlObjs = await _db.use((db) async { return await db.queryAlbumsByAlbumFileIds( account: ByAccount.db(account), fileIds: fileIds, ); }); return sqlObjs.toDbAlbums(); } @override Future<void> syncAlbum({ required DbAccount account, required DbFile albumFile, required DbAlbum album, }) async { final sqlAlbum = AlbumConverter.toSql(album); await _db.use((db) async { await db.syncAlbum( account: ByAccount.db(account), albumFileEtag: albumFile.etag, obj: sqlAlbum, ); }); } @override Future<List<DbFaceRecognitionPerson>> getFaceRecognitionPersons({ required DbAccount account, }) async { final sqlObjs = await _db.use((db) async { return await db.queryFaceRecognitionPersons( account: ByAccount.db(account), ); }); return sqlObjs.toDbFaceRecognitionPersons(); } @override Future<List<DbFaceRecognitionPerson>> searchFaceRecognitionPersonsByName({ required DbAccount account, required String name, }) async { final sqlObjs = await _db.use((db) async { return await db.searchFaceRecognitionPersonByName( account: ByAccount.db(account), name: name, ); }); return sqlObjs.toDbFaceRecognitionPersons(); } @override Future<DbSyncResult> syncFaceRecognitionPersons({ required DbAccount account, required List<DbFaceRecognitionPerson> persons, }) async { int sorter(DbFaceRecognitionPerson a, DbFaceRecognitionPerson b) => a.name.compareTo(b.name); final to = persons.sorted(sorter); return await _db.use((db) async { final sqlObjs = await db.queryFaceRecognitionPersons( account: ByAccount.db(account), ); final from = sqlObjs.map(FaceRecognitionPersonConverter.fromSql).sorted(sorter); final diff = getDiffWith(from, to, sorter); final inserts = diff.onlyInB; _log.info( "[replaceFaceRecognitionPersons] New persons: ${inserts.toReadableString()}"); final deletes = diff.onlyInA; _log.info( "[replaceFaceRecognitionPersons] Removed persons: ${deletes.toReadableString()}"); final updates = to.where((t) { final f = from.firstWhereOrNull((e) => e.name == t.name); return f != null && f != t; }).toList(); _log.info( "[replaceFaceRecognitionPersons] Updated persons: ${updates.toReadableString()}"); if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) { await db.replaceFaceRecognitionPersons( account: ByAccount.db(account), inserts: inserts, deletes: deletes, updates: updates, ); } return DbSyncResult( insert: inserts.length, delete: deletes.length, update: updates.length, ); }); } @override Future<List<DbFile>> getFilesByDirKey({ required DbAccount account, required DbFileKey dir, }) async { final sqlObjs = await _db.use((db) async { return await db.queryFilesByDirKey( account: ByAccount.db(account), dir: dir, ); }); return sqlObjs.toDbFiles(); } @override Future<List<DbFile>> getFilesByDirKeyAndLocation({ required DbAccount account, required String dirRelativePath, required String? place, required String countryCode, }) async { final sqlObjs = await _db.use((db) async { return await db.queryFilesByLocation( account: ByAccount.db(account), dirRelativePath: dirRelativePath, place: place, countryCode: countryCode, ); }); return sqlObjs.toDbFiles(); } @override Future<List<DbFile>> getFilesByFileIds({ required DbAccount account, required List<int> fileIds, }) async { final sqlObjs = await _db.use((db) async { return await db.queryFilesByFileIds( account: ByAccount.db(account), fileIds: fileIds, ); }); return sqlObjs.toDbFiles(); } @override Future<List<DbFile>> getFilesByTimeRange({ required DbAccount account, required List<String> dirRoots, required TimeRange range, }) async { final sqlObjs = await _db.use((db) async { return await db.queryFilesByTimeRange( account: ByAccount.db(account), dirRoots: dirRoots, range: range, ); }); return sqlObjs.toDbFiles(); } @override Future<void> updateFileByFileId({ required DbAccount account, required int fileId, String? relativePath, OrNull<bool>? isFavorite, OrNull<bool>? isArchived, OrNull<DateTime>? overrideDateTime, DateTime? bestDateTime, OrNull<DbImageData>? imageData, OrNull<DbLocation>? location, }) async { await _db.use((db) async { await db.updateFileByFileId( account: ByAccount.db(account), fileId: fileId, relativePath: relativePath, isFavorite: isFavorite, isArchived: isArchived, overrideDateTime: overrideDateTime, bestDateTime: bestDateTime, imageData: imageData, location: location, ); }); } @override Future<void> updateFilesByFileIds({ required DbAccount account, required List<int> fileIds, OrNull<bool>? isFavorite, OrNull<bool>? isArchived, }) async { await _db.use((db) async { await db.updateFilesByFileIds( account: ByAccount.db(account), fileIds: fileIds, isFavorite: isFavorite, isArchived: isArchived, ); }); } @override Future<void> syncDirFiles({ required DbAccount account, required DbFileKey dirFile, required List<DbFile> files, }) async { final sqlFiles = await files.toSql(); await _db.use((db) async { await db.syncDirFiles( account: ByAccount.db(account), dirFile: dirFile, objs: sqlFiles, ); }); } @override Future<void> syncFile({ required DbAccount account, required DbFile file, }) async { final sqlFile = FileConverter.toSql(file); await _db.use((db) async { await db.syncFile( account: ByAccount.db(account), obj: sqlFile, ); }); } @override Future<DbSyncIdResult> syncFavoriteFiles({ required DbAccount account, required List<int> favoriteFileIds, }) async { int sorter(int a, int b) => a.compareTo(b); final to = favoriteFileIds.sorted(sorter); return await _db.use((db) async { final sqlObjs = await db.queryFileIds( account: ByAccount.db(account), isFavorite: true, ); final from = sqlObjs.sorted(sorter); final diff = getDiffWith(from, to, sorter); final inserts = diff.onlyInB; _log.info( "[syncFavoriteFiles] New favorites: ${inserts.toReadableString()}"); final deletes = diff.onlyInA; _log.info( "[syncFavoriteFiles] Removed favorites: ${deletes.toReadableString()}"); if (inserts.isNotEmpty) { await db.updateFilesByFileIds( account: ByAccount.db(account), fileIds: inserts, isFavorite: const OrNull(true), ); } if (deletes.isNotEmpty) { await db.updateFilesByFileIds( account: ByAccount.db(account), fileIds: deletes, isFavorite: const OrNull(false), ); } return DbSyncIdResult( insert: inserts, delete: deletes, update: const [], ); }); } @override Future<int> countFilesByMissingMetadata({ required DbAccount account, required List<String> mimes, }) async { return _db.use((db) async { return await db.countFiles( account: ByAccount.db(account), isMissingMetadata: true, mimes: mimes, ); }); } @override Future<void> deleteFile({ required DbAccount account, required DbFileKey file, }) async { await _db.use((db) async { return await db.deleteFile( account: ByAccount.db(account), file: file, ); }); } @override Future<Map<int, String>> getDirFileIdToEtagByLikeRelativePath({ required DbAccount account, required String relativePath, }) async { return await _db.use((db) async { return await db.getDirFileIdToEtagByLikeRelativePath( account: ByAccount.db(account), relativePath: relativePath, ); }); } @override Future<void> truncateDir({ required DbAccount account, required DbFileKey dir, }) async { await _db.use((db) async { return await db.truncateDir( account: ByAccount.db(account), dir: dir, ); }); } @override Future<List<DbFileDescriptor>> getFileDescriptors({ required DbAccount account, List<int>? fileIds, List<String>? includeRelativeRoots, List<String>? includeRelativeDirs, List<String>? excludeRelativeRoots, List<String>? relativePathKeywords, String? location, bool? isFavorite, bool? isArchived, List<String>? mimes, TimeRange? timeRange, int? offset, int? limit, }) async { final sqlObjs = await _db.use((db) async { return await db.queryFileDescriptors( account: ByAccount.db(account), fileIds: fileIds, includeRelativeRoots: includeRelativeRoots, includeRelativeDirs: includeRelativeDirs, excludeRelativeRoots: excludeRelativeRoots, relativePathKeywords: relativePathKeywords, location: location, isFavorite: isFavorite, isArchived: isArchived, mimes: mimes, timeRange: timeRange, offset: offset, limit: limit, ); }); return sqlObjs.toDbFileDescriptors(); } @override Future<DbFilesSummary> getFilesSummary({ required DbAccount account, List<String>? includeRelativeRoots, List<String>? excludeRelativeRoots, List<String>? mimes, }) async { final result = await _db.use((db) async { return await db.countFileGroupsByDate( account: ByAccount.db(account), includeRelativeRoots: includeRelativeRoots, excludeRelativeRoots: excludeRelativeRoots, mimes: mimes, isArchived: false, ); }); return DbFilesSummary( items: result.dateCount .map((key, value) => MapEntry(key, DbFilesSummaryItem(count: value))), ); } @override Future<DbFilesMemory> getFilesMemories({ required DbAccount account, required Date at, required int radius, List<String>? includeRelativeRoots, List<String>? excludeRelativeRoots, List<String>? mimes, }) async { final result = await _db.use((db) async { return await db.queryFileDescriptorMemories( account: ByAccount.db(account), at: at, radius: radius, includeRelativeRoots: includeRelativeRoots, excludeRelativeRoots: excludeRelativeRoots, mimes: mimes, ); }); final memories = <int, List<DbFileDescriptor>>{}; for (final r in result.map(FileDescriptorConverter.fromSql)) { (memories[r.bestDateTime.year] ??= <DbFileDescriptor>[]).add(r); } return DbFilesMemory(memories: memories); } @override Future<DbLocationGroupResult> groupLocations({ required DbAccount account, List<String>? includeRelativeRoots, List<String>? excludeRelativeRoots, }) async { List<ImageLocationGroup>? nameResult, admin1Result, admin2Result, ccResult; await _db.use((db) async { try { nameResult = await db.groupImageLocationsByName( account: ByAccount.db(account), includeRelativeRoots: includeRelativeRoots, excludeRelativeRoots: excludeRelativeRoots, ); } catch (e, stackTrace) { _log.shout("[groupLocation] Failed while groupImageLocationsByName", e, stackTrace); } try { admin1Result = await db.groupImageLocationsByAdmin1( account: ByAccount.db(account), includeRelativeRoots: includeRelativeRoots, excludeRelativeRoots: excludeRelativeRoots, ); } catch (e, stackTrace) { _log.shout("[groupLocation] Failed while groupImageLocationsByAdmin1", e, stackTrace); } try { admin2Result = await db.groupImageLocationsByAdmin2( account: ByAccount.db(account), includeRelativeRoots: includeRelativeRoots, excludeRelativeRoots: excludeRelativeRoots, ); } catch (e, stackTrace) { _log.shout("[groupLocation] Failed while groupImageLocationsByAdmin2", e, stackTrace); } try { ccResult = await db.groupImageLocationsByCountryCode( account: ByAccount.db(account), includeRelativeRoots: includeRelativeRoots, excludeRelativeRoots: excludeRelativeRoots, ); } catch (e, stackTrace) { _log.shout( "[groupLocation] Failed while groupImageLocationsByCountryCode", e, stackTrace); } }); return DbLocationGroupResult( name: nameResult?.toDbLocationGroups() ?? [], admin1: admin1Result?.toDbLocationGroups() ?? [], admin2: admin2Result?.toDbLocationGroups() ?? [], countryCode: ccResult?.toDbLocationGroups() ?? [], ); } @override Future<List<DbNcAlbum>> getNcAlbums({ required DbAccount account, }) async { final sqlObjs = await _db.use((db) async { return await db.queryNcAlbums( account: ByAccount.db(account), ); }); return sqlObjs.toDbNcAlbums(); } @override Future<void> addNcAlbum({ required DbAccount account, required DbNcAlbum album, }) async { await _db.use((db) async { await db.insertNcAlbum(account: account, album: album); }); } @override Future<void> deleteNcAlbum({ required DbAccount account, required DbNcAlbum album, }) async { await _db.use((db) async { await db.deleteNcAlbum(account: account, album: album); }); } @override Future<DbSyncResult> syncNcAlbums({ required DbAccount account, required List<DbNcAlbum> albums, }) async { int sorter(DbNcAlbum a, DbNcAlbum b) => a.relativePath.compareTo(b.relativePath); final to = albums.sorted(sorter); return await _db.use((db) async { final sqlObjs = await db.queryNcAlbums( account: ByAccount.db(account), ); final from = sqlObjs.map(NcAlbumConverter.fromSql).sorted(sorter); final diff = getDiffWith(from, to, sorter); final inserts = diff.onlyInB; _log.info("[syncNcAlbums] New nc albums: ${inserts.toReadableString()}"); final deletes = diff.onlyInA; _log.info( "[syncNcAlbums] Removed nc albums: ${deletes.toReadableString()}"); final updates = to.where((t) { final f = from.firstWhereOrNull((e) => e.relativePath == t.relativePath); return f != null && f != t; }).toList(); _log.info( "[syncNcAlbums] Updated nc albums: ${updates.toReadableString()}"); if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) { await db.replaceNcAlbums( account: ByAccount.db(account), inserts: inserts, deletes: deletes, updates: updates, ); } return DbSyncResult( insert: inserts.length, delete: deletes.length, update: updates.length, ); }); } @override Future<List<DbNcAlbumItem>> getNcAlbumItemsByParent({ required DbAccount account, required DbNcAlbum parent, }) async { final sqlObjs = await _db.use((db) async { return await db.queryNcAlbumItemsByParentRelativePath( account: ByAccount.db(account), parentRelativePath: parent.relativePath, ); }); return sqlObjs.toDbNcAlbumItems(); } @override Future<DbSyncResult> syncNcAlbumItems({ required DbAccount account, required DbNcAlbum album, required List<DbNcAlbumItem> items, }) async { int sorter(DbNcAlbumItem a, DbNcAlbumItem b) => a.fileId.compareTo(b.fileId); final to = items.sorted(sorter); return await _db.use((db) async { final sqlObjs = await db.queryNcAlbumItemsByParentRelativePath( account: ByAccount.db(account), parentRelativePath: album.relativePath, ); final int parentRowId; if (sqlObjs.isNotEmpty) { parentRowId = sqlObjs.first.parent; } else { final parent = await db.queryNcAlbumByRelativePath( account: ByAccount.db(account), relativePath: album.relativePath, ); parentRowId = parent!.rowId; } final from = sqlObjs.map(NcAlbumItemConverter.fromSql).sorted(sorter); final diff = getDiffWith(from, to, sorter); final inserts = diff.onlyInB; _log.info( "[syncNcAlbumItems] New nc album items: ${inserts.toReadableString()}"); final deletes = diff.onlyInA; _log.info( "[syncNcAlbumItems] Removed nc album items: ${deletes.toReadableString()}"); final updates = to.where((t) { final f = from.firstWhereOrNull((e) => e.fileId == t.fileId); return f != null && f != t; }).toList(); _log.info( "[syncNcAlbumItems] Updated nc album items: ${updates.toReadableString()}"); if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) { await db.replaceNcAlbumItems( parentRowId: parentRowId, inserts: inserts, deletes: deletes, updates: updates, ); } return DbSyncResult( insert: inserts.length, delete: deletes.length, update: updates.length, ); }); } @override Future<List<DbRecognizeFace>> getRecognizeFaces({ required DbAccount account, }) async { final sqlObjs = await _db.use((db) async { return await db.queryRecognizeFaces( account: ByAccount.db(account), ); }); return sqlObjs.toDbRecognizeFaces(); } @override Future<List<DbRecognizeFaceItem>> getRecognizeFaceItemsByFaceLabel({ required DbAccount account, required String label, }) async { final sqlObjs = await _db.use((db) async { return await db.queryRecognizeFaceItemsByFaceLabel( account: ByAccount.db(account), label: label, ); }); return sqlObjs.toDbRecognizeFaceItems(); } @override Future<Map<String, List<DbRecognizeFaceItem>>> getRecognizeFaceItemsByFaceLabels({ required DbAccount account, required List<String> labels, ErrorWithValueHandler<String>? onError, }) async { final results = <String, List<RecognizeFaceItem>>{}; await _db.use((db) async { for (final l in labels) { try { results[l] = await db.queryRecognizeFaceItemsByFaceLabel( account: ByAccount.db(account), label: l, ); } catch (e, stackTrace) { onError?.call(l, e, stackTrace); } } }); return results.asyncMap((key, value) => value.toDbRecognizeFaceItems().then((v) => MapEntry(key, v))); } @override Future<Map<String, DbRecognizeFaceItem>> getLatestRecognizeFaceItemsByFaceLabels({ required DbAccount account, required List<String> labels, ErrorWithValueHandler<String>? onError, }) async { final results = <String, List<RecognizeFaceItem>>{}; await _db.use((db) async { for (final l in labels) { try { results[l] = await db.queryRecognizeFaceItemsByFaceLabel( account: ByAccount.db(account), label: l, orderBy: [RecognizeFaceItemSort.fileIdDesc], limit: 1, ); } catch (e, stackTrace) { onError?.call(l, e, stackTrace); } } }); return results.asyncMap((key, value) => value.toDbRecognizeFaceItems().then((v) => MapEntry(key, v.first))); } @override Future<bool> syncRecognizeFacesAndItems({ required DbAccount account, required Map<DbRecognizeFace, List<DbRecognizeFaceItem>> data, }) async { int sorter(DbRecognizeFace a, DbRecognizeFace b) => a.label.compareTo(b.label); int itemSorter(DbRecognizeFaceItem a, DbRecognizeFaceItem b) => a.fileId.compareTo(b.fileId); final faces = data.keys; final to = faces.sorted(sorter); final toItems = data.map((key, value) => MapEntry(key, value.sorted(itemSorter))); return await _db.use((db) async { var result = false; final sqlAccount = await db.accountOf(ByAccount.db(account)); final sqlObjs = await db.queryRecognizeFaces( account: ByAccount.sql(sqlAccount), ); final from = sqlObjs.map(RecognizeFaceConverter.fromSql).sorted(sorter); final diff = getDiffWith(from, to, sorter); final inserts = diff.onlyInB; _log.info( "[syncRecognizeFacesAndItems] New faces: ${inserts.toReadableString()}"); final deletes = diff.onlyInA; _log.info( "[syncRecognizeFacesAndItems] Removed faces: ${deletes.toReadableString()}"); final updates = to.where((t) { final f = from.firstWhereOrNull((e) => e.label == t.label); return f != null && f != t; }).toList(); _log.info( "[syncRecognizeFacesAndItems] Updated faces: ${updates.toReadableString()}"); if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) { await db.replaceRecognizeFaces( account: ByAccount.sql(sqlAccount), inserts: inserts, deletes: deletes, updates: updates, ); result = true; } sqlObjs.addAll(await db.queryRecognizeFaces( account: ByAccount.sql(sqlAccount), labels: inserts.map((e) => e.label).toList(), )); for (final d in data.entries) { try { result |= await _replaceRecognizeFaceItems( db, sqlAccount: sqlAccount, face: sqlObjs.firstWhere((e) => e.label == d.key.label), items: toItems[d.key]!, sorter: itemSorter, ); } catch (e, stackTrace) { _log.shout( "[syncRecognizeFacesAndItems] Failed to replace items for face: ${d.key}", e, stackTrace, ); } } return result; }); } @override Future<List<DbTag>> getTags({ required DbAccount account, }) async { final sqlObjs = await _db.use((db) async { return await db.queryTags( account: ByAccount.db(account), ); }); return sqlObjs.toDbTags(); } @override Future<DbTag?> getTagByDisplayName({ required DbAccount account, required String displayName, }) async { final sqlObj = await _db.use((db) async { return await db.queryTagByDisplayName( account: ByAccount.db(account), displayName: displayName, ); }); return sqlObj?.let(TagConverter.fromSql); } @override Future<DbSyncIdResult> syncTags({ required DbAccount account, required List<DbTag> tags, }) async { int sorter(DbTag a, DbTag b) => a.id.compareTo(b.id); final to = tags.sorted(sorter); return await _db.use((db) async { final sqlObjs = await db.queryTags( account: ByAccount.db(account), ); final from = sqlObjs.map(TagConverter.fromSql).sorted(sorter); final diff = getDiffWith(from, to, sorter); final inserts = diff.onlyInB; _log.info("[syncTags] New tags: ${inserts.toReadableString()}"); final deletes = diff.onlyInA; _log.info("[syncTags] Removed tags: ${deletes.toReadableString()}"); final updates = to.where((t) { final f = from.firstWhereOrNull((e) => e.id == t.id); return f != null && f != t; }).toList(); _log.info("[syncTags] Updated tags: ${updates.toReadableString()}"); if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) { await db.replaceTags( account: ByAccount.db(account), inserts: inserts, deletes: deletes, updates: updates, ); } return DbSyncIdResult( insert: inserts.map((e) => e.id).toList(), delete: deletes.map((e) => e.id).toList(), update: updates.map((e) => e.id).toList(), ); }); } @override Future<void> migrateV55( void Function(int current, int count)? onProgress) async { await _db.use((db) async { await db.migrateV55(onProgress); }); } @override Future<void> sqlVacuum() async { await _db.useNoTransaction((db) async { await db.customStatement("VACUUM;"); }); } Future<bool> _replaceRecognizeFaceItems( SqliteDb db, { required Account sqlAccount, required RecognizeFace face, required List<DbRecognizeFaceItem> items, required int Function(DbRecognizeFaceItem, DbRecognizeFaceItem) sorter, }) async { final to = items; final sqlObjs = await db.queryRecognizeFaceItemsByFaceLabel( account: ByAccount.sql(sqlAccount), label: face.label, ); final from = sqlObjs.map(RecognizeFaceItemConverter.fromSql).sorted(sorter); final diff = getDiffWith(from, to, sorter); final inserts = diff.onlyInB; _log.info( "[_replaceRecognizeFaceItems] New faces: ${inserts.toReadableString()}"); final deletes = diff.onlyInA; _log.info( "[_replaceRecognizeFaceItems] Removed faces: ${deletes.toReadableString()}"); final updates = to.where((t) { final f = from.firstWhereOrNull((e) => e.fileId == t.fileId); return f != null && f != t; }).toList(); _log.info( "[_replaceRecognizeFaceItems] Updated faces: ${updates.toReadableString()}"); if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) { await db.replaceRecognizeFaceItems( face: face, inserts: inserts, deletes: deletes, updates: updates, ); return true; } return false; } @Deprecated("For compatibility only") SqliteDb get compatDb => _db; late final SqliteDb _db; }