diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index a7ba0a5d..ecf597ea 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -31,15 +31,6 @@ android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" /> - - diff --git a/app/build.yaml b/app/build.yaml new file mode 100644 index 00000000..5cffa1e9 --- /dev/null +++ b/app/build.yaml @@ -0,0 +1,10 @@ +targets: + $default: + builders: + drift_dev: + options: + apply_converters_on_variables: true + generate_values_in_copy_with: true + new_sql_code_generation: true + scoped_dart_components: true + generate_connect_constructor: true diff --git a/app/lib/app_db.dart b/app/lib/app_db.dart deleted file mode 100644 index a2c7a97c..00000000 --- a/app/lib/app_db.dart +++ /dev/null @@ -1,499 +0,0 @@ -import 'dart:async'; - -import 'package:equatable/equatable.dart'; -import 'package:idb_shim/idb.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/ci_string.dart'; -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/album/upgrader.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/k.dart' as k; -import 'package:nc_photos/mobile/platform.dart' - if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform; -import 'package:nc_photos/num_extension.dart'; -import 'package:nc_photos/object_extension.dart'; -import 'package:nc_photos/platform/k.dart' as platform_k; -import 'package:nc_photos/type.dart'; - -class AppDb { - static const dbName = "app.db"; - static const dbVersion = 6; - static const albumStoreName = "albums"; - static const file2StoreName = "files2"; - static const dirStoreName = "dirs"; - static const metaStoreName = "meta"; - - /// Run [fn] with an opened database instance - /// - /// This function guarantees that: - /// 1) Database is always closed after [fn] exits, even with an error - /// 2) Only at most 1 database instance being opened at any time - Future use(Transaction Function(Database db) transactionBuilder, - FutureOr Function(Transaction transaction) fn) async { - // make sure only one client is opening the db - return await platform.Lock.synchronized(k.appDbLockId, () async { - final db = await _open(); - Transaction? transaction; - try { - transaction = transactionBuilder(db); - return await fn(transaction); - } finally { - if (transaction != null) { - await transaction.completed; - } - db.close(); - } - }); - } - - Future delete() async { - _log.warning("[delete] Deleting database"); - return await platform.Lock.synchronized(k.appDbLockId, () async { - final dbFactory = platform.getDbFactory(); - await dbFactory.deleteDatabase(dbName); - }); - } - - /// Open the database - Future _open() { - if (platform_k.isWeb) { - return _openNative(); - } else { - return _openSqflite(); - } - } - - /// Open the sqflite database - /// - /// We can't simply call deleteObjectStore on upgrade failure here, as the - /// package does not remove the corresponding indexes, so when we recreate - /// the indexes later, it'll fail. What we do here, is to delete the whole - /// database instead - Future _openSqflite() async { - final dbFactory = platform.getDbFactory(); - try { - int? fromVersion, toVersion; - final db = await dbFactory.open( - dbName, - version: dbVersion, - onUpgradeNeeded: (event) { - _upgrade(event); - fromVersion = event.oldVersion; - toVersion = event.newVersion; - }, - ); - if (fromVersion != null && toVersion != null) { - await _onPostUpgrade(db, fromVersion!, toVersion!); - } - return db; - } catch (e, stackTrace) { - _log.shout( - "[_openSqflite] Failed while upgrading database", e, stackTrace); - _log.warning("[_openSqflite] Recreating db"); - await dbFactory.deleteDatabase(dbName); - return dbFactory.open(dbName, - version: dbVersion, onUpgradeNeeded: _upgrade); - } - } - - /// Open the native IndexedDB database - /// - /// Errors thrown in onUpgradeNeeded are not propagated properly to us on web, - /// so the sqflite approach will not work - Future _openNative() async { - final dbFactory = platform.getDbFactory(); - int? fromVersion, toVersion; - final db = await dbFactory.open( - dbName, - version: dbVersion, - onUpgradeNeeded: (event) { - try { - _upgrade(event); - fromVersion = event.oldVersion; - toVersion = event.newVersion; - } catch (e, stackTrace) { - _log.shout( - "[_openNative] Failed while upgrading database", e, stackTrace); - // drop the db and rebuild a new one instead - try { - event.database.deleteObjectStore(albumStoreName); - } catch (_) {} - try { - event.database.deleteObjectStore(file2StoreName); - } catch (_) {} - try { - event.database.deleteObjectStore(dirStoreName); - } catch (_) {} - try { - event.database.deleteObjectStore(metaStoreName); - } catch (_) {} - try { - event.database.deleteObjectStore(_fileDbStoreName); - } catch (_) {} - try { - event.database.deleteObjectStore(_fileStoreName); - } catch (_) {} - _log.warning("[_openNative] Recreating db"); - _upgrade(_DummyVersionChangeEvent( - 0, - event.newVersion, - event.transaction, - event.target, - event.currentTarget, - event.database)); - } - }, - ); - if (fromVersion != null && toVersion != null) { - await _onPostUpgrade(db, fromVersion!, toVersion!); - } - return db; - } - - void _upgrade(VersionChangeEvent event) { - _log.info("[_upgrade] Upgrade database: ${event.oldVersion} -> $dbVersion"); - - final db = event.database; - // ignore: unused_local_variable - ObjectStore? albumStore, file2Store, dirStore, metaStore; - if (event.oldVersion < 2) { - // version 2 store things in a new way, just drop all - try { - db.deleteObjectStore(albumStoreName); - } catch (_) {} - albumStore = db.createObjectStore(albumStoreName); - albumStore.createIndex( - AppDbAlbumEntry.indexName, AppDbAlbumEntry.keyPath); - } - if (event.oldVersion < 3) { - // new object store in v3 - // no longer relevant in v4 - - // recreate file store from scratch - // no longer relevant in v4 - } - if (event.oldVersion < 4) { - try { - db.deleteObjectStore(_fileDbStoreName); - } catch (_) {} - try { - db.deleteObjectStore(_fileStoreName); - } catch (_) {} - - file2Store = db.createObjectStore(file2StoreName); - file2Store.createIndex(AppDbFile2Entry.strippedPathIndexName, - AppDbFile2Entry.strippedPathKeyPath); - - dirStore = db.createObjectStore(dirStoreName); - } - file2Store ??= event.transaction.objectStore(file2StoreName); - if (event.oldVersion < 5) { - file2Store.createIndex(AppDbFile2Entry.dateTimeEpochMsIndexName, - AppDbFile2Entry.dateTimeEpochMsKeyPath); - - metaStore = - db.createObjectStore(metaStoreName, keyPath: AppDbMetaEntry.keyPath); - } - if (event.oldVersion < 6) { - file2Store.createIndex(AppDbFile2Entry.fileIsFavoriteIndexName, - AppDbFile2Entry.fileIsFavoriteKeyPath); - } - } - - Future _onPostUpgrade( - Database db, int fromVersion, int toVersion) async { - if (fromVersion.inRange(1, 4) && toVersion >= 5) { - final transaction = db.transaction(AppDb.metaStoreName, idbModeReadWrite); - final metaStore = transaction.objectStore(AppDb.metaStoreName); - await metaStore - .put(const AppDbMetaEntryDbCompatV5(false).toEntry().toJson()); - await transaction.completed; - } - } - - static const _fileDbStoreName = "filesDb"; - static const _fileStoreName = "files"; - - static final _log = Logger("app_db.AppDb"); -} - -class AppDbAlbumEntry { - static const indexName = "albumStore_path_index"; - static const keyPath = ["path", "index"]; - static const maxDataSize = 160; - - AppDbAlbumEntry(this.path, this.index, this.album); - - JsonObj toJson() { - return { - "path": path, - "index": index, - "album": album.toAppDbJson(), - }; - } - - factory AppDbAlbumEntry.fromJson(JsonObj json, Account account) { - return AppDbAlbumEntry( - json["path"], - json["index"], - Album.fromJson( - json["album"].cast(), - upgraderFactory: DefaultAlbumUpgraderFactory( - account: account, - logFilePath: json["path"], - ), - )!, - ); - } - - static String toPath(Account account, String filePath) => - "${account.url}/$filePath"; - static String toPathFromFile(Account account, File albumFile) => - toPath(account, albumFile.path); - static String toPrimaryKey(Account account, File albumFile, int index) => - "${toPathFromFile(account, albumFile)}[$index]"; - - final String path; - final int index; - // properties other than Album.items is undefined when index > 0 - final Album album; -} - -class AppDbFile2Entry with EquatableMixin { - static const strippedPathIndexName = "server_userId_strippedPath"; - static const strippedPathKeyPath = ["server", "userId", "strippedPath"]; - - static const dateTimeEpochMsIndexName = "server_userId_dateTimeEpochMs"; - static const dateTimeEpochMsKeyPath = ["server", "userId", "dateTimeEpochMs"]; - - static const fileIsFavoriteIndexName = "server_userId_fileIsFavorite"; - static const fileIsFavoriteKeyPath = ["server", "userId", "file.isFavorite"]; - - AppDbFile2Entry(this.server, this.userId, this.strippedPath, - this.dateTimeEpochMs, this.file); - - factory AppDbFile2Entry.fromFile(Account account, File file) => - AppDbFile2Entry(account.url, account.username, file.strippedPathWithEmpty, - file.bestDateTime.millisecondsSinceEpoch, file); - - factory AppDbFile2Entry.fromJson(JsonObj json) => AppDbFile2Entry( - json["server"], - (json["userId"] as String).toCi(), - json["strippedPath"], - json["dateTimeEpochMs"], - File.fromJson(json["file"].cast()), - ); - - JsonObj toJson() => { - "server": server, - "userId": userId.toCaseInsensitiveString(), - "strippedPath": strippedPath, - "dateTimeEpochMs": dateTimeEpochMs, - "file": file.toJson(), - }; - - static String toPrimaryKey(Account account, int fileId) => - "${account.url}/${account.username.toCaseInsensitiveString()}/$fileId"; - - static String toPrimaryKeyForFile(Account account, File file) => - toPrimaryKey(account, file.fileId!); - - static List toStrippedPathIndexKey( - Account account, String strippedPath) => - [ - account.url, - account.username.toCaseInsensitiveString(), - strippedPath == "." ? "" : strippedPath - ]; - - static List toStrippedPathIndexKeyForFile( - Account account, File file) => - toStrippedPathIndexKey(account, file.strippedPathWithEmpty); - - /// Return the lower bound key used to query files under [dir] and its sub - /// dirs - static List toStrippedPathIndexLowerKeyForDir( - Account account, File dir) => - [ - account.url, - account.username.toCaseInsensitiveString(), - dir.strippedPath.run((p) => p == "." ? "" : "$p/") - ]; - - /// Return the upper bound key used to query files under [dir] and its sub - /// dirs - static List toStrippedPathIndexUpperKeyForDir( - Account account, File dir) { - return toStrippedPathIndexLowerKeyForDir(account, dir).run((k) { - k[2] = (k[2] as String) + "\uffff"; - return k; - }); - } - - static List toDateTimeEpochMsIndexKey(Account account, int epochMs) => - [ - account.url, - account.username.toCaseInsensitiveString(), - epochMs, - ]; - - static List toFileIsFavoriteIndexKey( - Account account, bool isFavorite) => - [ - account.url, - account.username.toCaseInsensitiveString(), - isFavorite ? 1 : 0, - ]; - - @override - get props => [ - server, - userId, - strippedPath, - dateTimeEpochMs, - file, - ]; - - /// Server URL where this file belongs to - final String server; - final CiString userId; - final String strippedPath; - final int dateTimeEpochMs; - final File file; -} - -class AppDbDirEntry with EquatableMixin { - AppDbDirEntry._( - this.server, this.userId, this.strippedPath, this.dir, this.children); - - factory AppDbDirEntry.fromFiles( - Account account, File dir, List children) => - AppDbDirEntry._( - account.url, - account.username, - dir.strippedPathWithEmpty, - dir, - children.map((f) => f.fileId!).toList(), - ); - - factory AppDbDirEntry.fromJson(JsonObj json) => AppDbDirEntry._( - json["server"], - (json["userId"] as String).toCi(), - json["strippedPath"], - File.fromJson((json["dir"] as Map).cast()), - json["children"].cast(), - ); - - JsonObj toJson() => { - "server": server, - "userId": userId.toCaseInsensitiveString(), - "strippedPath": strippedPath, - "dir": dir.toJson(), - "children": children, - }; - - static String toPrimaryKeyForDir(Account account, File dir) => - "${account.url}/${account.username.toCaseInsensitiveString()}/${dir.strippedPathWithEmpty}"; - - /// Return the lower bound key used to query dirs under [root] and its sub - /// dirs - static String toPrimaryLowerKeyForSubDirs(Account account, File root) { - final strippedPath = root.strippedPath.run((p) => p == "." ? "" : "$p/"); - return "${account.url}/${account.username.toCaseInsensitiveString()}/$strippedPath"; - } - - /// Return the upper bound key used to query dirs under [root] and its sub - /// dirs - static String toPrimaryUpperKeyForSubDirs(Account account, File root) => - toPrimaryLowerKeyForSubDirs(account, root) + "\uffff"; - - @override - get props => [ - server, - userId, - strippedPath, - dir, - children, - ]; - - /// Server URL where this file belongs to - final String server; - final CiString userId; - final String strippedPath; - final File dir; - final List children; -} - -class AppDbMetaEntry with EquatableMixin { - static const keyPath = "key"; - - const AppDbMetaEntry(this.key, this.obj); - - factory AppDbMetaEntry.fromJson(JsonObj json) => AppDbMetaEntry( - json["key"], - json["obj"].cast(), - ); - - JsonObj toJson() => { - "key": key, - "obj": obj, - }; - - @override - get props => [ - key, - obj, - ]; - - final String key; - final JsonObj obj; -} - -class AppDbMetaEntryDbCompatV5 { - static const key = "dbCompatV5"; - - const AppDbMetaEntryDbCompatV5(this.isMigrated); - - factory AppDbMetaEntryDbCompatV5.fromJson(JsonObj json) => - AppDbMetaEntryDbCompatV5(json["isMigrated"]); - - AppDbMetaEntry toEntry() => AppDbMetaEntry(key, { - "isMigrated": isMigrated, - }); - - final bool isMigrated; -} - -class AppDbMetaEntryCompatV37 { - static const key = "compatV37"; - - const AppDbMetaEntryCompatV37(this.isMigrated); - - factory AppDbMetaEntryCompatV37.fromJson(JsonObj json) => - AppDbMetaEntryCompatV37(json["isMigrated"]); - - AppDbMetaEntry toEntry() => AppDbMetaEntry(key, { - "isMigrated": isMigrated, - }); - - final bool isMigrated; -} - -class _DummyVersionChangeEvent implements VersionChangeEvent { - const _DummyVersionChangeEvent(this.oldVersion, this.newVersion, - this.transaction, this.target, this.currentTarget, this.database); - - @override - final int oldVersion; - @override - final int newVersion; - @override - final Transaction transaction; - @override - final Object target; - @override - final Object currentTarget; - @override - final Database database; -} diff --git a/app/lib/app_init.dart b/app/lib/app_init.dart index f750357d..e5ac9639 100644 --- a/app/lib/app_init.dart +++ b/app/lib/app_init.dart @@ -1,12 +1,13 @@ +import 'package:drift/drift.dart'; import 'package:equatable/equatable.dart'; import 'package:event_bus/event_bus.dart'; import 'package:flutter/foundation.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/data_source.dart'; import 'package:nc_photos/entity/face.dart'; import 'package:nc_photos/entity/face/data_source.dart'; import 'package:nc_photos/entity/favorite.dart'; @@ -21,6 +22,8 @@ import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/share/data_source.dart'; import 'package:nc_photos/entity/sharee.dart'; import 'package:nc_photos/entity/sharee/data_source.dart'; +import 'package:nc_photos/entity/sqlite_table.dart' as sql; +import 'package:nc_photos/entity/sqlite_table_isolate.dart' as sql_isolate; import 'package:nc_photos/entity/tag.dart'; import 'package:nc_photos/entity/tag/data_source.dart'; import 'package:nc_photos/entity/tagged_file.dart'; @@ -34,13 +37,19 @@ import 'package:nc_photos/pref.dart'; import 'package:nc_photos/pref_util.dart' as pref_util; import 'package:visibility_detector/visibility_detector.dart'; -Future initAppLaunch() async { +enum InitIsolateType { + main, + service, +} + +Future init(InitIsolateType isolateType) async { if (_hasInitedInThisIsolate) { _log.warning("[initAppLaunch] Already initialized in this isolate"); return; } initLog(); + _initDrift(); _initKiwi(); await _initPref(); await _initAccountPrefs(); @@ -49,7 +58,7 @@ Future initAppLaunch() async { if (features.isSupportSelfSignedCert) { _initSelfSignedCertManager(); } - _initDiContainer(); + await _initDiContainer(isolateType); _initVisibilityDetector(); _hasInitedInThisIsolate = true; @@ -99,6 +108,10 @@ void initLog() { }); } +void _initDrift() { + driftRuntimeOptions.debugPrint = _debugPrintSql; +} + Future _initPref() async { final provider = PrefSharedPreferencesProvider(); await provider.init(); @@ -146,31 +159,56 @@ void _initSelfSignedCertManager() { SelfSignedCertManager().init(); } -void _initDiContainer() { - LocalFileRepo? localFileRepo; +Future _initDiContainer(InitIsolateType isolateType) async { + final c = DiContainer.late(); + c.pref = Pref(); + c.sqliteDb = await _createDb(isolateType); + + c.albumRepo = AlbumRepo(AlbumCachedDataSource(c)); + c.albumRepoLocal = AlbumRepo(AlbumSqliteDbDataSource(c)); + c.faceRepo = const FaceRepo(FaceRemoteDataSource()); + c.fileRepo = FileRepo(FileCachedDataSource(c)); + c.fileRepoRemote = const FileRepo(FileWebdavDataSource()); + c.fileRepoLocal = FileRepo(FileSqliteDbDataSource(c)); + c.personRepo = const PersonRepo(PersonRemoteDataSource()); + c.shareRepo = ShareRepo(ShareRemoteDataSource()); + c.shareeRepo = ShareeRepo(ShareeRemoteDataSource()); + c.favoriteRepo = const FavoriteRepo(FavoriteRemoteDataSource()); + c.tagRepo = const TagRepo(TagRemoteDataSource()); + c.taggedFileRepo = const TaggedFileRepo(TaggedFileRemoteDataSource()); + if (platform_k.isAndroid) { // local file currently only supported on Android - localFileRepo = const LocalFileRepo(LocalFileMediaStoreDataSource()); + c.localFileRepo = const LocalFileRepo(LocalFileMediaStoreDataSource()); } - KiwiContainer().registerInstance(DiContainer( - albumRepo: AlbumRepo(AlbumCachedDataSource(AppDb())), - faceRepo: const FaceRepo(FaceRemoteDataSource()), - fileRepo: FileRepo(FileCachedDataSource(AppDb())), - personRepo: const PersonRepo(PersonRemoteDataSource()), - shareRepo: ShareRepo(ShareRemoteDataSource()), - shareeRepo: ShareeRepo(ShareeRemoteDataSource()), - favoriteRepo: const FavoriteRepo(FavoriteRemoteDataSource()), - tagRepo: const TagRepo(TagRemoteDataSource()), - taggedFileRepo: const TaggedFileRepo(TaggedFileRemoteDataSource()), - localFileRepo: localFileRepo, - appDb: AppDb(), - pref: Pref(), - )); + + KiwiContainer().registerInstance(c); } void _initVisibilityDetector() { VisibilityDetectorController.instance.updateInterval = Duration.zero; } +Future _createDb(InitIsolateType isolateType) async { + switch (isolateType) { + case InitIsolateType.main: + // use driftIsolate to prevent DB blocking the UI thread + if (platform_k.isWeb) { + // no isolate support on web + return sql.SqliteDb(); + } else { + return sql_isolate.createDb(); + } + + case InitIsolateType.service: + // service already runs in an isolate + return sql.SqliteDb(); + } +} + +void _debugPrintSql(String log) { + _log.finer(log); +} + final _log = Logger("app_init"); var _hasInitedInThisIsolate = false; diff --git a/app/lib/bloc/list_album.dart b/app/lib/bloc/list_album.dart index b9d89f8c..54ea8898 100644 --- a/app/lib/bloc/list_album.dart +++ b/app/lib/bloc/list_album.dart @@ -5,8 +5,6 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/event/event.dart'; @@ -153,8 +151,8 @@ class ListAlbumBloc extends Bloc { _log.info("[of] New bloc instance for account: $account"); final c = KiwiContainer().resolve(); final offlineC = c.copyWith( - fileRepo: OrNull(FileRepo(FileAppDbDataSource(c.appDb))), - albumRepo: OrNull(AlbumRepo(AlbumAppDbDataSource(c.appDb))), + fileRepo: OrNull(c.fileRepoLocal), + albumRepo: OrNull(c.albumRepoLocal), ); final bloc = ListAlbumBloc(c, offlineC); KiwiContainer().registerInstance(bloc, name: name); diff --git a/app/lib/bloc/ls_trashbin.dart b/app/lib/bloc/ls_trashbin.dart index 18aeb83d..00c38758 100644 --- a/app/lib/bloc/ls_trashbin.dart +++ b/app/lib/bloc/ls_trashbin.dart @@ -3,8 +3,8 @@ import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/throttler.dart'; @@ -175,9 +175,9 @@ class LsTrashbinBloc extends Bloc { } Future> _query(LsTrashbinBlocQuery ev) async { + final c = KiwiContainer().resolve(); // caching contents in trashbin doesn't sounds useful - const fileRepo = FileRepo(FileWebdavDataSource()); - final files = await LsTrashbin(fileRepo)(ev.account); + final files = await LsTrashbin(c.fileRepoRemote)(ev.account); return files.where((f) => file_util.isSupportedFormat(f)).toList(); } diff --git a/app/lib/bloc/scan_account_dir.dart b/app/lib/bloc/scan_account_dir.dart index 3703b849..4d159222 100644 --- a/app/lib/bloc/scan_account_dir.dart +++ b/app/lib/bloc/scan_account_dir.dart @@ -5,7 +5,6 @@ import 'package:collection/collection.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; @@ -108,6 +107,11 @@ class ScanAccountDirBlocInconsistent extends ScanAccountDirBlocState { class ScanAccountDirBloc extends Bloc { ScanAccountDirBloc._(this.account) : super(const ScanAccountDirBlocInit()) { + final c = KiwiContainer().resolve(); + assert(require(c)); + assert(ScanDirOffline.require(c)); + _c = c; + _fileRemovedEventListener.begin(); _filePropertyUpdatedEventListener.begin(); _fileTrashbinRestoredEventListener.begin(); @@ -132,6 +136,8 @@ class ScanAccountDirBloc })); } + static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo); + static ScanAccountDirBloc of(Account account) { final name = bloc_util.getInstNameForRootAwareAccount("ScanAccountDirBloc", account); @@ -317,12 +323,11 @@ class ScanAccountDirBloc } Future> _queryOffline(ScanAccountDirBlocQueryBase ev) async { - final c = KiwiContainer().resolve(); final files = []; for (final r in account.roots) { try { final dir = File(path: file_util.unstripPath(account, r)); - files.addAll(await ScanDirOffline(c)(account, dir, + files.addAll(await ScanDirOffline(_c)(account, dir, isOnlySupportedFormat: false)); } catch (e, stackTrace) { _log.shout( @@ -341,11 +346,12 @@ class ScanAccountDirBloc final cacheMap = FileForwardCacheManager.prepareFileMap(cache); { final stopwatch = Stopwatch()..start(); - final fileRepo = FileRepo(FileCachedDataSource(AppDb(), - forwardCacheManager: FileForwardCacheManager(AppDb(), cacheMap))); - final fileRepoNoCache = FileRepo(FileCachedDataSource(AppDb())); + final fileRepo = FileRepo(FileCachedDataSource( + _c, + forwardCacheManager: FileForwardCacheManager(_c, cacheMap), + )); await for (final event in _queryWithFileRepo(fileRepo, ev, - fileRepoForShareDir: fileRepoNoCache)) { + fileRepoForShareDir: _c.fileRepo)) { if (event is ExceptionEvent) { _log.shout("[_queryOnline] Exception while request (1st pass)", event.error, event.stackTrace); @@ -378,7 +384,8 @@ class ScanAccountDirBloc emit(ScanAccountDirBlocLoading(files)); } - files = await _queryOnlinePass2(ev, cacheMap, files); + // files = await _queryOnlinePass2(ev, cacheMap, files); + files = await _queryOnlinePass2(ev, {}, files); } } catch (e, stackTrace) { _log.shout( @@ -394,9 +401,11 @@ class ScanAccountDirBloc // files final pass2CacheMap = CombinedMapView( [FileForwardCacheManager.prepareFileMap(pass1Files), cacheMap]); - final fileRepo = FileRepo(FileCachedDataSource(AppDb(), - shouldCheckCache: true, - forwardCacheManager: FileForwardCacheManager(AppDb(), pass2CacheMap))); + final fileRepo = FileRepo(FileCachedDataSource( + _c, + shouldCheckCache: true, + forwardCacheManager: FileForwardCacheManager(_c, pass2CacheMap), + )); final remoteTouchEtag = await touchTokenManager.getRemoteRootEtag(fileRepo, account); if (remoteTouchEtag == null) { @@ -412,7 +421,7 @@ class ScanAccountDirBloc final stopwatch = Stopwatch()..start(); final fileRepoNoCache = - FileRepo(FileCachedDataSource(AppDb(), shouldCheckCache: true)); + FileRepo(FileCachedDataSource(_c, shouldCheckCache: true)); final newFiles = []; await for (final event in _queryWithFileRepo(fileRepo, ev, fileRepoForShareDir: fileRepoNoCache)) { @@ -489,6 +498,8 @@ class ScanAccountDirBloc return false; } + late final DiContainer _c; + final Account account; late final _fileRemovedEventListener = diff --git a/app/lib/di_container.dart b/app/lib/di_container.dart index 36e58978..0401a037 100644 --- a/app/lib/di_container.dart +++ b/app/lib/di_container.dart @@ -1,4 +1,3 @@ -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/face.dart'; import 'package:nc_photos/entity/favorite.dart'; @@ -7,6 +6,7 @@ import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/sharee.dart'; +import 'package:nc_photos/entity/sqlite_table.dart' as sql; import 'package:nc_photos/entity/tag.dart'; import 'package:nc_photos/entity/tagged_file.dart'; import 'package:nc_photos/or_null.dart'; @@ -14,8 +14,11 @@ import 'package:nc_photos/pref.dart'; enum DiType { albumRepo, + albumRepoLocal, faceRepo, fileRepo, + fileRepoRemote, + fileRepoLocal, personRepo, shareRepo, shareeRepo, @@ -23,15 +26,18 @@ enum DiType { tagRepo, taggedFileRepo, localFileRepo, - appDb, pref, + sqliteDb, } class DiContainer { - const DiContainer({ + DiContainer({ AlbumRepo? albumRepo, + AlbumRepo? albumRepoLocal, FaceRepo? faceRepo, FileRepo? fileRepo, + FileRepo? fileRepoRemote, + FileRepo? fileRepoLocal, PersonRepo? personRepo, ShareRepo? shareRepo, ShareeRepo? shareeRepo, @@ -39,11 +45,14 @@ class DiContainer { TagRepo? tagRepo, TaggedFileRepo? taggedFileRepo, LocalFileRepo? localFileRepo, - AppDb? appDb, Pref? pref, + sql.SqliteDb? sqliteDb, }) : _albumRepo = albumRepo, + _albumRepoLocal = albumRepoLocal, _faceRepo = faceRepo, _fileRepo = fileRepo, + _fileRepoRemote = fileRepoRemote, + _fileRepoLocal = fileRepoLocal, _personRepo = personRepo, _shareRepo = shareRepo, _shareeRepo = shareeRepo, @@ -51,17 +60,25 @@ class DiContainer { _tagRepo = tagRepo, _taggedFileRepo = taggedFileRepo, _localFileRepo = localFileRepo, - _appDb = appDb, - _pref = pref; + _pref = pref, + _sqliteDb = sqliteDb; + + DiContainer.late(); static bool has(DiContainer contianer, DiType type) { switch (type) { case DiType.albumRepo: return contianer._albumRepo != null; + case DiType.albumRepoLocal: + return contianer._albumRepoLocal != null; case DiType.faceRepo: return contianer._faceRepo != null; case DiType.fileRepo: return contianer._fileRepo != null; + case DiType.fileRepoRemote: + return contianer._fileRepoRemote != null; + case DiType.fileRepoLocal: + return contianer._fileRepoLocal != null; case DiType.personRepo: return contianer._personRepo != null; case DiType.shareRepo: @@ -76,17 +93,20 @@ class DiContainer { return contianer._taggedFileRepo != null; case DiType.localFileRepo: return contianer._localFileRepo != null; - case DiType.appDb: - return contianer._appDb != null; case DiType.pref: return contianer._pref != null; + case DiType.sqliteDb: + return contianer._sqliteDb != null; } } DiContainer copyWith({ OrNull? albumRepo, + OrNull? albumRepoLocal, OrNull? faceRepo, OrNull? fileRepo, + OrNull? fileRepoRemote, + OrNull? fileRepoLocal, OrNull? personRepo, OrNull? shareRepo, OrNull? shareeRepo, @@ -94,13 +114,18 @@ class DiContainer { OrNull? tagRepo, OrNull? taggedFileRepo, OrNull? localFileRepo, - OrNull? appDb, OrNull? pref, + OrNull? sqliteDb, }) { return DiContainer( albumRepo: albumRepo == null ? _albumRepo : albumRepo.obj, + albumRepoLocal: + albumRepoLocal == null ? _albumRepoLocal : albumRepoLocal.obj, faceRepo: faceRepo == null ? _faceRepo : faceRepo.obj, fileRepo: fileRepo == null ? _fileRepo : fileRepo.obj, + fileRepoRemote: + fileRepoRemote == null ? _fileRepoRemote : fileRepoRemote.obj, + fileRepoLocal: fileRepoLocal == null ? _fileRepoLocal : fileRepoLocal.obj, personRepo: personRepo == null ? _personRepo : personRepo.obj, shareRepo: shareRepo == null ? _shareRepo : shareRepo.obj, shareeRepo: shareeRepo == null ? _shareeRepo : shareeRepo.obj, @@ -109,14 +134,17 @@ class DiContainer { taggedFileRepo: taggedFileRepo == null ? _taggedFileRepo : taggedFileRepo.obj, localFileRepo: localFileRepo == null ? _localFileRepo : localFileRepo.obj, - appDb: appDb == null ? _appDb : appDb.obj, pref: pref == null ? _pref : pref.obj, + sqliteDb: sqliteDb == null ? _sqliteDb : sqliteDb.obj, ); } AlbumRepo get albumRepo => _albumRepo!; + AlbumRepo get albumRepoLocal => _albumRepoLocal!; FaceRepo get faceRepo => _faceRepo!; FileRepo get fileRepo => _fileRepo!; + FileRepo get fileRepoRemote => _fileRepoRemote!; + FileRepo get fileRepoLocal => _fileRepoLocal!; PersonRepo get personRepo => _personRepo!; ShareRepo get shareRepo => _shareRepo!; ShareeRepo get shareeRepo => _shareeRepo!; @@ -125,20 +153,101 @@ class DiContainer { TaggedFileRepo get taggedFileRepo => _taggedFileRepo!; LocalFileRepo get localFileRepo => _localFileRepo!; - AppDb get appDb => _appDb!; + sql.SqliteDb get sqliteDb => _sqliteDb!; Pref get pref => _pref!; - final AlbumRepo? _albumRepo; - final FaceRepo? _faceRepo; - final FileRepo? _fileRepo; - final PersonRepo? _personRepo; - final ShareRepo? _shareRepo; - final ShareeRepo? _shareeRepo; - final FavoriteRepo? _favoriteRepo; - final TagRepo? _tagRepo; - final TaggedFileRepo? _taggedFileRepo; - final LocalFileRepo? _localFileRepo; + set albumRepo(AlbumRepo v) { + assert(_albumRepo == null); + _albumRepo = v; + } - final AppDb? _appDb; - final Pref? _pref; + set albumRepoLocal(AlbumRepo v) { + assert(_albumRepoLocal == null); + _albumRepoLocal = v; + } + + set faceRepo(FaceRepo v) { + assert(_faceRepo == null); + _faceRepo = v; + } + + set fileRepo(FileRepo v) { + assert(_fileRepo == null); + _fileRepo = v; + } + + set fileRepoRemote(FileRepo v) { + assert(_fileRepoRemote == null); + _fileRepoRemote = v; + } + + set fileRepoLocal(FileRepo v) { + assert(_fileRepoLocal == null); + _fileRepoLocal = v; + } + + set personRepo(PersonRepo v) { + assert(_personRepo == null); + _personRepo = v; + } + + set shareRepo(ShareRepo v) { + assert(_shareRepo == null); + _shareRepo = v; + } + + set shareeRepo(ShareeRepo v) { + assert(_shareeRepo == null); + _shareeRepo = v; + } + + set favoriteRepo(FavoriteRepo v) { + assert(_favoriteRepo == null); + _favoriteRepo = v; + } + + set tagRepo(TagRepo v) { + assert(_tagRepo == null); + _tagRepo = v; + } + + set taggedFileRepo(TaggedFileRepo v) { + assert(_taggedFileRepo == null); + _taggedFileRepo = v; + } + + set localFileRepo(LocalFileRepo v) { + assert(_localFileRepo == null); + _localFileRepo = v; + } + + set sqliteDb(sql.SqliteDb v) { + assert(_sqliteDb == null); + _sqliteDb = v; + } + + set pref(Pref v) { + assert(_pref == null); + _pref = v; + } + + AlbumRepo? _albumRepo; + // Explicitly request a AlbumRepo backed by local source + AlbumRepo? _albumRepoLocal; + FaceRepo? _faceRepo; + FileRepo? _fileRepo; + // Explicitly request a FileRepo backed by remote source + FileRepo? _fileRepoRemote; + // Explicitly request a FileRepo backed by local source + FileRepo? _fileRepoLocal; + PersonRepo? _personRepo; + ShareRepo? _shareRepo; + ShareeRepo? _shareeRepo; + FavoriteRepo? _favoriteRepo; + TagRepo? _tagRepo; + TaggedFileRepo? _taggedFileRepo; + LocalFileRepo? _localFileRepo; + + sql.SqliteDb? _sqliteDb; + Pref? _pref; } diff --git a/app/lib/entity/album.dart b/app/lib/entity/album.dart index d58dc9bd..87d3b187 100644 --- a/app/lib/entity/album.dart +++ b/app/lib/entity/album.dart @@ -1,33 +1,16 @@ -import 'dart:convert'; -import 'dart:math'; - import 'package:equatable/equatable.dart'; -import 'package:idb_sqflite/idb_sqflite.dart'; -import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/ci_string.dart'; -import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album/cover_provider.dart'; -import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/album/sort_provider.dart'; import 'package:nc_photos/entity/album/upgrader.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file/data_source.dart'; -import 'package:nc_photos/exception.dart'; -import 'package:nc_photos/int_util.dart' as int_util; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/or_null.dart'; -import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; import 'package:nc_photos/type.dart'; -import 'package:nc_photos/use_case/get_file_binary.dart'; -import 'package:nc_photos/use_case/ls_single_file.dart'; -import 'package:nc_photos/use_case/put_file_binary.dart'; -import 'package:quiver/iterables.dart'; -import 'package:tuple/tuple.dart'; /// Immutable object that represents an album class Album with EquatableMixin { @@ -229,6 +212,8 @@ class Album with EquatableMixin { /// versioning of this class, use to upgrade old persisted album static const version = 8; + + static final _log = Logger("entity.album.Album"); } class AlbumShare with EquatableMixin { @@ -299,6 +284,10 @@ class AlbumRepo { Future get(Account account, File albumFile) => dataSrc.get(account, albumFile); + /// See [AlbumDataSource.getAll] + Stream getAll(Account account, List albumFiles) => + dataSrc.getAll(account, albumFiles); + /// See [AlbumDataSource.create] Future create(Account account, Album album) => dataSrc.create(account, album); @@ -307,11 +296,6 @@ class AlbumRepo { Future update(Account account, Album album) => dataSrc.update(account, album); - /// See [AlbumDataSource.cleanUp] - Future cleanUp( - Account account, String rootDir, List albumFiles) => - dataSrc.cleanUp(account, rootDir, albumFiles); - final AlbumDataSource dataSrc; } @@ -319,292 +303,12 @@ abstract class AlbumDataSource { /// Return the album defined by [albumFile] Future get(Account account, File albumFile); + /// Emit albums defined by [albumFiles] or ExceptionEvent + Stream getAll(Account account, List albumFiles); + // Create a new album Future create(Account account, Album album); /// Update an album Future update(Account account, Album album); - - /// Clean up cached albums - /// - /// Remove dangling albums in cache not listed in [albumFiles] and located - /// inside [rootDir]. Do nothing if this data source does not cache previous - /// results - Future cleanUp(Account account, String rootDir, List albumFiles); } - -class AlbumRemoteDataSource implements AlbumDataSource { - @override - get(Account account, File albumFile) async { - _log.info("[get] ${albumFile.path}"); - const fileRepo = FileRepo(FileWebdavDataSource()); - final data = await GetFileBinary(fileRepo)(account, albumFile); - try { - return Album.fromJson( - jsonDecode(utf8.decode(data)), - upgraderFactory: DefaultAlbumUpgraderFactory( - account: account, - albumFile: albumFile, - logFilePath: albumFile.path, - ), - )! - .copyWith( - lastUpdated: OrNull(null), - albumFile: OrNull(albumFile), - ); - } catch (e, stacktrace) { - dynamic d = data; - try { - d = utf8.decode(data); - } catch (_) {} - _log.severe("[get] Invalid json data: $d", e, stacktrace); - throw const FormatException("Invalid album format"); - } - } - - @override - create(Account account, Album album) async { - _log.info("[create]"); - final fileName = _makeAlbumFileName(); - final filePath = - "${remote_storage_util.getRemoteAlbumsDir(account)}/$fileName"; - const fileRepo = FileRepo(FileWebdavDataSource()); - await PutFileBinary(fileRepo)(account, filePath, - const Utf8Encoder().convert(jsonEncode(album.toRemoteJson())), - shouldCreateMissingDir: true); - // query album file - final newFile = await LsSingleFile(KiwiContainer().resolve())( - account, filePath); - return album.copyWith(albumFile: OrNull(newFile)); - } - - @override - update(Account account, Album album) async { - _log.info("[update] ${album.albumFile!.path}"); - const fileRepo = FileRepo(FileWebdavDataSource()); - await PutFileBinary(fileRepo)(account, album.albumFile!.path, - const Utf8Encoder().convert(jsonEncode(album.toRemoteJson()))); - } - - @override - cleanUp(Account account, String rootDir, List albumFiles) async {} - - String _makeAlbumFileName() { - // just make up something - final timestamp = DateTime.now().millisecondsSinceEpoch; - final random = Random().nextInt(0xFFFFFF); - return "${timestamp.toRadixString(16)}-${random.toRadixString(16).padLeft(6, '0')}.nc_album.json"; - } - - static final _log = Logger("entity.album.AlbumRemoteDataSource"); -} - -class AlbumAppDbDataSource implements AlbumDataSource { - const AlbumAppDbDataSource(this.appDb); - - @override - get(Account account, File albumFile) { - _log.info("[get] ${albumFile.path}"); - return appDb.use( - (db) => db.transaction(AppDb.albumStoreName, idbModeReadOnly), - (transaction) async { - final store = transaction.objectStore(AppDb.albumStoreName); - final index = store.index(AppDbAlbumEntry.indexName); - final path = AppDbAlbumEntry.toPathFromFile(account, albumFile); - final range = KeyRange.bound([path, 0], [path, int_util.int32Max]); - final List results = await index.getAll(range); - if (results.isNotEmpty == true) { - final entries = results.map((e) => - AppDbAlbumEntry.fromJson(e.cast(), account)); - if (entries.length > 1) { - final items = entries.map((e) { - _log.info("[get] ${e.path}[${e.index}]"); - return AlbumStaticProvider.of(e.album).items; - }).reduce((value, element) => value + element); - return entries.first.album.copyWith( - lastUpdated: OrNull(null), - provider: AlbumStaticProvider.of(entries.first.album).copyWith( - items: items, - ), - ); - } else { - return entries.first.album; - } - } else { - throw CacheNotFoundException("No entry: $path"); - } - }, - ); - } - - @override - create(Account account, Album album) async { - _log.info("[create]"); - throw UnimplementedError(); - } - - @override - update(Account account, Album album) { - _log.info("[update] ${album.albumFile!.path}"); - return appDb.use( - (db) => db.transaction(AppDb.albumStoreName, idbModeReadWrite), - (transaction) async { - final store = transaction.objectStore(AppDb.albumStoreName); - await _cacheAlbum(store, account, album); - }, - ); - } - - @override - cleanUp(Account account, String rootDir, List albumFiles) async {} - - final AppDb appDb; - - static final _log = Logger("entity.album.AlbumAppDbDataSource"); -} - -class AlbumCachedDataSource implements AlbumDataSource { - AlbumCachedDataSource(this.appDb) : _appDbSrc = AlbumAppDbDataSource(appDb); - - @override - get(Account account, File albumFile) async { - try { - final cache = await _appDbSrc.get(account, albumFile); - if (cache.albumFile!.etag?.isNotEmpty == true && - cache.albumFile!.etag == albumFile.etag) { - // cache is good - _log.fine( - "[get] etag matched for ${AppDbAlbumEntry.toPathFromFile(account, albumFile)}"); - return cache; - } - _log.info( - "[get] Remote content updated for ${AppDbAlbumEntry.toPathFromFile(account, albumFile)}"); - } on CacheNotFoundException catch (_) { - // normal when there's no cache - } catch (e, stacktrace) { - _log.shout("[get] Cache failure", e, stacktrace); - } - - // no cache - final remote = await _remoteSrc.get(account, albumFile); - await _cacheResult(account, remote); - return remote; - } - - @override - update(Account account, Album album) async { - await _remoteSrc.update(account, album); - await _appDbSrc.update(account, album); - } - - @override - create(Account account, Album album) => _remoteSrc.create(account, album); - - @override - cleanUp(Account account, String rootDir, List albumFiles) async { - appDb.use( - (db) => db.transaction(AppDb.albumStoreName, idbModeReadWrite), - (transaction) async { - final store = transaction.objectStore(AppDb.albumStoreName); - final index = store.index(AppDbAlbumEntry.indexName); - final rootPath = AppDbAlbumEntry.toPath(account, rootDir); - final range = KeyRange.bound( - ["$rootPath/", 0], ["$rootPath/\uffff", int_util.int32Max]); - final danglingKeys = await index - // get all albums for this account - .openKeyCursor(range: range, autoAdvance: true) - .map((cursor) => Tuple2((cursor.key as List)[0], cursor.primaryKey)) - // and pick the dangling ones - .where((pair) => !albumFiles.any((f) => - pair.item1 == AppDbAlbumEntry.toPathFromFile(account, f))) - // map to primary keys - .map((pair) => pair.item2) - .toList(); - for (final k in danglingKeys) { - _log.fine("[cleanUp] Removing albumStore entry: $k"); - try { - await store.delete(k); - } catch (e, stackTrace) { - _log.shout( - "[cleanUp] Failed removing albumStore entry", e, stackTrace); - } - } - }, - ); - } - - Future _cacheResult(Account account, Album result) { - return appDb.use( - (db) => db.transaction(AppDb.albumStoreName, idbModeReadWrite), - (transaction) async { - final store = transaction.objectStore(AppDb.albumStoreName); - await _cacheAlbum(store, account, result); - }, - ); - } - - final AppDb appDb; - final _remoteSrc = AlbumRemoteDataSource(); - final AlbumAppDbDataSource _appDbSrc; - - static final _log = Logger("entity.album.AlbumCachedDataSource"); -} - -Future _cacheAlbum( - ObjectStore store, Account account, Album album) async { - final index = store.index(AppDbAlbumEntry.indexName); - final path = AppDbAlbumEntry.toPathFromFile(account, album.albumFile!); - final range = KeyRange.bound([path, 0], [path, int_util.int32Max]); - // count number of entries for this album - final count = await index.count(range); - - // cut large album into smaller pieces, needed to workaround Android DB - // limitation - final entries = []; - if (album.provider is AlbumStaticProvider) { - var albumItemLists = partition( - AlbumStaticProvider.of(album).items, AppDbAlbumEntry.maxDataSize) - .toList(); - if (albumItemLists.isEmpty) { - albumItemLists = [[]]; - } - entries.addAll(albumItemLists.withIndex().map((pair) => AppDbAlbumEntry( - path, - pair.item1, - album.copyWith( - lastUpdated: OrNull(null), - provider: AlbumStaticProvider.of(album).copyWith( - items: pair.item2, - ), - )))); - } else { - entries.add(AppDbAlbumEntry(path, 0, album)); - } - - for (final e in entries) { - _log.info("[_cacheAlbum] Caching ${e.path}[${e.index}]"); - await store.put(e.toJson(), - AppDbAlbumEntry.toPrimaryKey(account, e.album.albumFile!, e.index)); - } - - if (count > entries.length) { - // index is 0-based - final rmRange = - KeyRange.bound([path, entries.length], [path, int_util.int32Max]); - final rmKeys = await index - .openKeyCursor(range: rmRange, autoAdvance: true) - .map((cursor) => cursor.primaryKey) - .toList(); - for (final k in rmKeys) { - _log.fine("[_cacheAlbum] Removing albumStore entry: $k"); - try { - await store.delete(k); - } catch (e, stackTrace) { - _log.shout( - "[_cacheAlbum] Failed removing albumStore entry", e, stackTrace); - } - } - } -} - -final _log = Logger("entity.album"); diff --git a/app/lib/entity/album/data_source.dart b/app/lib/entity/album/data_source.dart new file mode 100644 index 00000000..d8ae1293 --- /dev/null +++ b/app/lib/entity/album/data_source.dart @@ -0,0 +1,307 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:drift/drift.dart' as sql; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/upgrader.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/sqlite_table.dart' as sql; +import 'package:nc_photos/entity/sqlite_table_converter.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; +import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/future_util.dart' as future_util; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/or_null.dart'; +import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; +import 'package:nc_photos/use_case/get_file_binary.dart'; +import 'package:nc_photos/use_case/ls_single_file.dart'; +import 'package:nc_photos/use_case/put_file_binary.dart'; + +class AlbumRemoteDataSource implements AlbumDataSource { + @override + get(Account account, File albumFile) async { + _log.info("[get] ${albumFile.path}"); + const fileRepo = FileRepo(FileWebdavDataSource()); + final data = await GetFileBinary(fileRepo)(account, albumFile); + try { + return Album.fromJson( + jsonDecode(utf8.decode(data)), + upgraderFactory: DefaultAlbumUpgraderFactory( + account: account, + albumFile: albumFile, + logFilePath: albumFile.path, + ), + )! + .copyWith( + lastUpdated: OrNull(null), + albumFile: OrNull(albumFile), + ); + } catch (e, stacktrace) { + dynamic d = data; + try { + d = utf8.decode(data); + } catch (_) {} + _log.severe("[get] Invalid json data: $d", e, stacktrace); + throw const FormatException("Invalid album format"); + } + } + + @override + getAll(Account account, List albumFiles) async* { + _log.info( + "[getAll] ${albumFiles.map((f) => f.filename).toReadableString()}"); + final results = await future_util.waitOr( + albumFiles.map((f) => get(account, f)), + (error, stackTrace) => ExceptionEvent(error, stackTrace), + ); + for (final r in results) { + yield r; + } + } + + @override + create(Account account, Album album) async { + _log.info("[create]"); + final fileName = _makeAlbumFileName(); + final filePath = + "${remote_storage_util.getRemoteAlbumsDir(account)}/$fileName"; + const fileRepo = FileRepo(FileWebdavDataSource()); + await PutFileBinary(fileRepo)(account, filePath, + const Utf8Encoder().convert(jsonEncode(album.toRemoteJson())), + shouldCreateMissingDir: true); + // query album file + final newFile = await LsSingleFile(KiwiContainer().resolve())( + account, filePath); + return album.copyWith(albumFile: OrNull(newFile)); + } + + @override + update(Account account, Album album) async { + _log.info("[update] ${album.albumFile!.path}"); + const fileRepo = FileRepo(FileWebdavDataSource()); + await PutFileBinary(fileRepo)(account, album.albumFile!.path, + const Utf8Encoder().convert(jsonEncode(album.toRemoteJson()))); + } + + String _makeAlbumFileName() { + // just make up something + final timestamp = DateTime.now().millisecondsSinceEpoch; + final random = Random().nextInt(0xFFFFFF); + return "${timestamp.toRadixString(16)}-${random.toRadixString(16).padLeft(6, '0')}.nc_album.json"; + } + + static final _log = Logger("entity.album.AlbumRemoteDataSource"); +} + +class AlbumSqliteDbDataSource implements AlbumDataSource { + AlbumSqliteDbDataSource(this._c); + + @override + get(Account account, File albumFile) async { + final results = await getAll(account, [albumFile]).toList(); + if (results.first is! Album) { + throw results.first; + } else { + return results.first; + } + } + + @override + getAll(Account account, List albumFiles) async* { + _log.info( + "[getAll] ${albumFiles.map((f) => f.filename).toReadableString()}"); + late final List dbFiles; + late final List albumWithShares; + await _c.sqliteDb.use((db) async { + dbFiles = await db.completeFilesByFileIds( + albumFiles.map((f) => f.fileId!), + appAccount: account, + ); + final query = db.select(db.albums).join([ + sql.leftOuterJoin( + db.albumShares, db.albumShares.album.equalsExp(db.albums.rowId)), + ]) + ..where(db.albums.file.isIn(dbFiles.map((f) => f.file.rowId))); + albumWithShares = await query + .map((r) => sql.AlbumWithShare( + r.readTable(db.albums), r.readTableOrNull(db.albumShares))) + .get(); + }); + + // group entries together + final fileRowIdMap = {}; + for (var f in dbFiles) { + fileRowIdMap[f.file.rowId] = f; + } + final fileIdMap = {}; + for (final s in albumWithShares) { + final f = fileRowIdMap[s.album.file]; + if (f == null) { + _log.severe("[getAll] File missing for album (rowId: ${s.album.rowId}"); + } else { + if (!fileIdMap.containsKey(f.file.fileId)) { + fileIdMap[f.file.fileId] = { + "file": f, + "album": s.album, + }; + } + if (s.share != null) { + (fileIdMap[f.file.fileId]!["shares"] ??= []) + .add(s.share!); + } + } + } + + // sort as the input list + for (final item in albumFiles.map((f) => fileIdMap[f.fileId])) { + if (item == null) { + // cache not found + yield CacheNotFoundException(); + } else { + try { + final f = SqliteFileConverter.fromSql( + account.homeDir.toString(), item["file"]); + yield SqliteAlbumConverter.fromSql( + item["album"], f, item["shares"] ?? []); + } catch (e, stackTrace) { + _log.severe( + "[getAll] Failed while converting DB entry", e, stackTrace); + yield ExceptionEvent(e, stackTrace); + } + } + } + } + + @override + create(Account account, Album album) async { + _log.info("[create]"); + throw UnimplementedError(); + } + + @override + update(Account account, Album album) async { + _log.info("[update] ${album.albumFile!.path}"); + await _c.sqliteDb.use((db) async { + final rowIds = + await db.accountFileRowIdsOf(album.albumFile!, appAccount: account); + final insert = SqliteAlbumConverter.toSql(album, rowIds.fileRowId); + var rowId = await _updateCache(db, rowIds.fileRowId, insert.album); + if (rowId == null) { + // new album, need insert + _log.info("[update] Insert new album"); + final insertedAlbum = + await db.into(db.albums).insertReturning(insert.album); + rowId = insertedAlbum.rowId; + } else { + await (db.delete(db.albumShares)..where((t) => t.album.equals(rowId))) + .go(); + } + if (insert.albumShares.isNotEmpty) { + await db.batch((batch) { + batch.insertAll( + db.albumShares, + insert.albumShares.map((s) => s.copyWith(album: sql.Value(rowId!))), + ); + }); + } + }); + } + + Future _updateCache( + sql.SqliteDb db, int dbFileRowId, sql.AlbumsCompanion dbAlbum) async { + final rowIdQuery = db.selectOnly(db.albums) + ..addColumns([db.albums.rowId]) + ..where(db.albums.file.equals(dbFileRowId)) + ..limit(1); + final rowId = + await rowIdQuery.map((r) => r.read(db.albums.rowId)!).getSingleOrNull(); + if (rowId == null) { + // new album + return null; + } + + await (db.update(db.albums)..where((t) => t.rowId.equals(rowId))) + .write(dbAlbum); + return rowId; + } + + final DiContainer _c; + + static final _log = Logger("entity.album.AlbumSqliteDbDataSource"); +} + +class AlbumCachedDataSource implements AlbumDataSource { + AlbumCachedDataSource(DiContainer c) + : _sqliteDbSrc = AlbumSqliteDbDataSource(c); + + @override + get(Account account, File albumFile) async { + final result = await getAll(account, [albumFile]).first; + return result as Album; + } + + @override + getAll(Account account, List albumFiles) async* { + var i = 0; + await for (final cache in _sqliteDbSrc.getAll(account, albumFiles)) { + final albumFile = albumFiles[i++]; + if (_validateCache(cache, albumFile)) { + yield cache; + } else { + // no cache + final remote = await _remoteSrc.get(account, albumFile); + await _cacheResult(account, remote); + yield remote; + } + } + } + + @override + update(Account account, Album album) async { + await _remoteSrc.update(account, album); + await _sqliteDbSrc.update(account, album); + } + + @override + create(Account account, Album album) => _remoteSrc.create(account, album); + + Future _cacheResult(Account account, Album result) { + return _sqliteDbSrc.update(account, result); + } + + bool _validateCache(dynamic cache, File albumFile) { + if (cache is Album) { + if (cache.albumFile!.etag?.isNotEmpty == true && + cache.albumFile!.etag == albumFile.etag) { + // cache is good + _log.fine("[_validateCache] etag matched for ${albumFile.path}"); + return true; + } else { + _log.info( + "[_validateCache] Remote content updated for ${albumFile.path}"); + return false; + } + } else if (cache is CacheNotFoundException) { + // normal when there's no cache + return false; + } else if (cache is ExceptionEvent) { + _log.shout( + "[_validateCache] Cache failure", cache.error, cache.stackTrace); + return false; + } else { + _log.shout("[_validateCache] Unknown type: ${cache.runtimeType}"); + return false; + } + } + + final _remoteSrc = AlbumRemoteDataSource(); + final AlbumSqliteDbDataSource _sqliteDbSrc; + + static final _log = Logger("entity.album.AlbumCachedDataSource"); +} diff --git a/app/lib/entity/file.dart b/app/lib/entity/file.dart index 120b22ba..63eeedcb 100644 --- a/app/lib/entity/file.dart +++ b/app/lib/entity/file.dart @@ -379,7 +379,7 @@ class File with EquatableMixin { String? path, int? contentLength, String? contentType, - String? etag, + OrNull? etag, DateTime? lastModified, bool? isCollection, int? usedBytes, @@ -398,7 +398,7 @@ class File with EquatableMixin { path: path ?? this.path, contentLength: contentLength ?? this.contentLength, contentType: contentType ?? this.contentType, - etag: etag ?? this.etag, + etag: etag == null ? this.etag : etag.obj, lastModified: lastModified ?? this.lastModified, isCollection: isCollection ?? this.isCollection, usedBytes: usedBytes ?? this.usedBytes, @@ -447,7 +447,6 @@ class File with EquatableMixin { final bool? isCollection; final int? usedBytes; final bool? hasPreview; - // maybe null when loaded from old cache final int? fileId; final bool? isFavorite; final CiString? ownerId; diff --git a/app/lib/entity/file/data_source.dart b/app/lib/entity/file/data_source.dart index 759cf1dd..4baf9053 100644 --- a/app/lib/entity/file/data_source.dart +++ b/app/lib/entity/file/data_source.dart @@ -1,15 +1,17 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:idb_shim/idb_client.dart'; +import 'package:drift/drift.dart' as sql; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/debug_util.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/file_cache_manager.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/entity/sqlite_table.dart' as sql; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/entity/webdav_response_parser.dart'; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/iterable_extension.dart'; @@ -18,7 +20,6 @@ import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/touch_token_manager.dart'; import 'package:nc_photos/use_case/compat/v32.dart'; import 'package:path/path.dart' as path_lib; -import 'package:tuple/tuple.dart'; import 'package:uuid/uuid.dart'; import 'package:xml/xml.dart'; @@ -265,55 +266,38 @@ class FileWebdavDataSource implements FileDataSource { static final _log = Logger("entity.file.data_source.FileWebdavDataSource"); } -class FileAppDbDataSource implements FileDataSource { - const FileAppDbDataSource(this.appDb); +class FileSqliteDbDataSource implements FileDataSource { + FileSqliteDbDataSource(this._c); @override list(Account account, File dir) async { _log.info("[list] ${dir.path}"); - final dbItems = await appDb.use( - (db) => db.transaction( - [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadOnly), - (transaction) async { - final fileStore = transaction.objectStore(AppDb.file2StoreName); - final dirStore = transaction.objectStore(AppDb.dirStoreName); - final dirItem = await dirStore - .getObject(AppDbDirEntry.toPrimaryKeyForDir(account, dir)) as Map?; - if (dirItem == null) { - throw CacheNotFoundException("No entry: ${dir.path}"); - } - final dirEntry = - AppDbDirEntry.fromJson(dirItem.cast()); - return Tuple2( - dirEntry.dir, - await Future.wait( - dirEntry.children.map((c) async { - final fileItem = await fileStore - .getObject(AppDbFile2Entry.toPrimaryKey(account, c)) as Map?; - if (fileItem == null) { - _log.warning( - "[list] Missing file ($c) in db for dir: ${logFilename(dir.path)}"); - throw CacheNotFoundException("No entry for dir child: $c"); - } else { - return fileItem; - } - }), - eagerError: true, - ), - ); - }, - ); - // we need to add dir to match the remote query - return [ - dbItems.item1, - ...(await dbItems.item2.computeAll(_covertAppDbFile2Entry)) - .where((f) => _validateFile(f)) - ]; + final dbFiles = await _c.sqliteDb.use((db) async { + final dbAccount = await db.accountOf(account); + final sql.File dbDir; + try { + dbDir = await db.fileOf(dir, sqlAccount: dbAccount); + } catch (_) { + throw CacheNotFoundException("No entry: ${dir.path}"); + } + return await db.completeFilesByDirRowId(dbDir.rowId, + sqlAccount: dbAccount); + }); + final results = (await dbFiles.convertToAppFile(account)) + .where((f) => _validateFile(f)) + .toList(); + _log.fine("[list] Queried ${results.length} files"); + if (results.isEmpty) { + // each dir will at least contain its own entry, so an empty list here + // means that the dir has not been queried before + throw CacheNotFoundException("No entry: ${dir.path}"); + } + return results; } @override listSingle(Account account, File f) { - _log.info("[listSingle] ${f.path}"); + _log.severe("[listSingle] ${f.path}"); throw UnimplementedError(); } @@ -322,39 +306,41 @@ class FileAppDbDataSource implements FileDataSource { Future> listByDate( Account account, int fromEpochMs, int toEpochMs) async { _log.info("[listByDate] [$fromEpochMs, $toEpochMs]"); - final items = await appDb.use( - (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), - (transaction) async { - final fileStore = transaction.objectStore(AppDb.file2StoreName); - final dateTimeEpochMsIndex = - fileStore.index(AppDbFile2Entry.dateTimeEpochMsIndexName); - final range = KeyRange.bound( - AppDbFile2Entry.toDateTimeEpochMsIndexKey(account, fromEpochMs), - AppDbFile2Entry.toDateTimeEpochMsIndexKey(account, toEpochMs), - false, - true, - ); - return await dateTimeEpochMsIndex.getAll(range); - }, - ); - return items - .cast() - .map((i) => AppDbFile2Entry.fromJson(i.cast())) - .map((e) => e.file) - .where((f) => _validateFile(f)) - .toList(); + final dbFiles = await _c.sqliteDb.use((db) async { + final dateTime = sql.coalesce([ + db.accountFiles.overrideDateTime, + db.images.dateTimeOriginal, + db.files.lastModified, + ]).secondsSinceEpoch; + final queryBuilder = db.queryFiles() + ..setQueryMode(sql.FilesQueryMode.completeFile) + ..setAppAccount(account); + final query = queryBuilder.build(); + query + ..where(dateTime.isBetweenValues( + fromEpochMs ~/ 1000, (toEpochMs ~/ 1000) - 1)) + ..orderBy([sql.OrderingTerm.desc(dateTime)]); + return await query + .map((r) => sql.CompleteFile( + r.readTable(db.files), + r.readTable(db.accountFiles), + r.readTableOrNull(db.images), + r.readTableOrNull(db.trashes), + )) + .get(); + }); + return await dbFiles.convertToAppFile(account); } - /// Remove a file/dir from database @override remove(Account account, File f) { _log.info("[remove] ${f.path}"); - return FileCacheRemover(appDb)(account, f); + return FileSqliteCacheRemover(_c)(account, f); } @override getBinary(Account account, File f) { - _log.info("[getBinary] ${f.path}"); + _log.severe("[getBinary] ${f.path}"); throw UnimplementedError(); } @@ -365,30 +351,51 @@ class FileAppDbDataSource implements FileDataSource { } @override - updateProperty( - Account account, - File f, { - OrNull? metadata, - OrNull? isArchived, - OrNull? overrideDateTime, - bool? favorite, - }) { + updateProperty(Account account, File f, + {OrNull? metadata, + OrNull? isArchived, + OrNull? overrideDateTime, + bool? favorite}) async { _log.info("[updateProperty] ${f.path}"); - return appDb.use( - (db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite), - (transaction) async { - // update file store - final newFile = f.copyWith( - metadata: metadata, - isArchived: isArchived, - overrideDateTime: overrideDateTime, - isFavorite: favorite, + await _c.sqliteDb.use((db) async { + final rowIds = await db.accountFileRowIdsOf(f, appAccount: account); + if (isArchived != null || overrideDateTime != null || favorite != null) { + final update = sql.AccountFilesCompanion( + isArchived: isArchived == null + ? const sql.Value.absent() + : sql.Value(isArchived.obj), + overrideDateTime: overrideDateTime == null + ? const sql.Value.absent() + : sql.Value(overrideDateTime.obj), + isFavorite: + favorite == null ? const sql.Value.absent() : sql.Value(favorite), ); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - await fileStore.put(AppDbFile2Entry.fromFile(account, newFile).toJson(), - AppDbFile2Entry.toPrimaryKeyForFile(account, newFile)); - }, - ); + await (db.update(db.accountFiles) + ..where((t) => t.rowId.equals(rowIds.accountFileRowId))) + .write(update); + } + if (metadata != null) { + if (metadata.obj == null) { + await (db.delete(db.images) + ..where((t) => t.accountFile.equals(rowIds.accountFileRowId))) + .go(); + } else { + await db + .into(db.images) + .insertOnConflictUpdate(sql.ImagesCompanion.insert( + accountFile: sql.Value(rowIds.accountFileRowId), + lastUpdated: metadata.obj!.lastUpdated, + fileEtag: sql.Value(metadata.obj!.fileEtag), + width: sql.Value(metadata.obj!.imageWidth), + height: sql.Value(metadata.obj!.imageHeight), + exifRaw: sql.Value( + metadata.obj!.exif?.toJson().run((j) => jsonEncode(j))), + dateTimeOriginal: + sql.Value(metadata.obj!.exif?.dateTimeOriginal), + )); + } + } + }); } @override @@ -416,23 +423,23 @@ class FileAppDbDataSource implements FileDataSource { // do nothing } - final AppDb appDb; + final DiContainer _c; - static final _log = Logger("entity.file.data_source.FileAppDbDataSource"); + static final _log = Logger("entity.file.data_source.FileSqliteDbDataSource"); } class FileCachedDataSource implements FileDataSource { FileCachedDataSource( - this.appDb, { + this._c, { this.shouldCheckCache = false, this.forwardCacheManager, - }) : _appDbSrc = FileAppDbDataSource(appDb); + }) : _sqliteDbSrc = FileSqliteDbDataSource(_c); @override list(Account account, File dir) async { final cacheLoader = FileCacheLoader( - appDb: appDb, - appDbSrc: _appDbSrc, + _c, + cacheSrc: _sqliteDbSrc, remoteSrc: _remoteSrc, shouldCheckCache: shouldCheckCache, forwardCacheManager: forwardCacheManager, @@ -445,7 +452,7 @@ class FileCachedDataSource implements FileDataSource { // no cache or outdated try { final remote = await _remoteSrc.list(account, dir); - await FileCacheUpdater(appDb)(account, dir, remote: remote, cache: cache); + await FileSqliteCacheUpdater(_c)(account, dir, remote: remote); if (shouldCheckCache) { // update our local touch token to match the remote one const tokenManager = TouchTokenManager(); @@ -461,11 +468,15 @@ class FileCachedDataSource implements FileDataSource { } on ApiException catch (e) { if (e.response.statusCode == 404) { _log.info("[list] File removed: $dir"); - _appDbSrc.remove(account, dir); + if (cache != null) { + await _sqliteDbSrc.remove(account, dir); + } return []; } else if (e.response.statusCode == 403) { _log.info("[list] E2E encrypted dir: $dir"); - _appDbSrc.remove(account, dir); + if (cache != null) { + await _sqliteDbSrc.remove(account, dir); + } return []; } else { rethrow; @@ -480,7 +491,7 @@ class FileCachedDataSource implements FileDataSource { @override remove(Account account, File f) async { - await _appDbSrc.remove(account, f); + await _sqliteDbSrc.remove(account, f); await _remoteSrc.remove(account, f); } @@ -503,23 +514,22 @@ class FileCachedDataSource implements FileDataSource { OrNull? overrideDateTime, bool? favorite, }) async { - await _remoteSrc - .updateProperty( - account, - f, - metadata: metadata, - isArchived: isArchived, - overrideDateTime: overrideDateTime, - favorite: favorite, - ) - .then((_) => _appDbSrc.updateProperty( - account, - f, - metadata: metadata, - isArchived: isArchived, - overrideDateTime: overrideDateTime, - favorite: favorite, - )); + await _remoteSrc.updateProperty( + account, + f, + metadata: metadata, + isArchived: isArchived, + overrideDateTime: overrideDateTime, + favorite: favorite, + ); + await _sqliteDbSrc.updateProperty( + account, + f, + metadata: metadata, + isArchived: isArchived, + overrideDateTime: overrideDateTime, + favorite: favorite, + ); // generate a new random token final token = const Uuid().v4().replaceAll("-", ""); @@ -559,12 +569,12 @@ class FileCachedDataSource implements FileDataSource { await _remoteSrc.createDir(account, path); } - final AppDb appDb; + final DiContainer _c; final bool shouldCheckCache; final FileForwardCacheManager? forwardCacheManager; final _remoteSrc = const FileWebdavDataSource(); - final FileAppDbDataSource _appDbSrc; + final FileSqliteDbDataSource _sqliteDbSrc; static final _log = Logger("entity.file.data_source.FileCachedDataSource"); } @@ -576,7 +586,11 @@ class FileCachedDataSource implements FileDataSource { /// passed to us in one transaction. For this reason, this should only be used /// when it's necessary to query everything class FileForwardCacheManager { - FileForwardCacheManager(this.appDb, this.knownFiles); + FileForwardCacheManager(this._c, Map knownFiles) { + _fileCache.addAll(knownFiles); + } + + static bool require(DiContainer c) => true; /// Transform a list of files to a map suitable to be passed as the /// [knownFiles] argument @@ -587,117 +601,155 @@ class FileForwardCacheManager { Future> list(Account account, File dir) async { // check cache - final dirKey = AppDbDirEntry.toPrimaryKeyForDir(account, dir); - final cachedDir = _dirCache[dirKey]; - if (cachedDir != null) { + var childFileIds = _dirCache[dir.strippedPathWithEmpty]; + if (childFileIds == null) { + _log.info( + "[list] No cache and querying everything under ${logFilename(dir.path)}"); + await _cacheDir(account, dir); + childFileIds = _dirCache[dir.strippedPathWithEmpty]; + if (childFileIds == null) { + throw CacheNotFoundException("No entry: ${dir.path}"); + } + } else { _log.fine("[list] Returning data from cache: ${logFilename(dir.path)}"); - return _withDirEntry(cachedDir); } - // no cache, query everything under [dir] - _log.info( - "[list] No cache and querying everything under ${logFilename(dir.path)}"); - await _cacheDir(account, dir); - final cachedDir2 = _dirCache[dirKey]; - if (cachedDir2 == null) { - throw CacheNotFoundException("No entry: ${dir.path}"); - } - return _withDirEntry(cachedDir2); + return _listByFileIds(dir, childFileIds); } Future _cacheDir(Account account, File dir) async { - final dirItems = await appDb.use( - (db) => db.transaction(AppDb.dirStoreName, idbModeReadOnly), - (transaction) async { - final store = transaction.objectStore(AppDb.dirStoreName); - final dirItem = await store - .getObject(AppDbDirEntry.toPrimaryKeyForDir(account, dir)) as Map?; - if (dirItem == null) { - return null; - } - final range = KeyRange.bound( - AppDbDirEntry.toPrimaryLowerKeyForSubDirs(account, dir), - AppDbDirEntry.toPrimaryUpperKeyForSubDirs(account, dir), - ); - return [dirItem] + (await store.getAll(range)).cast(); - }, - ); - if (dirItems == null) { + await _c.sqliteDb.use((db) async { + final dbAccount = await db.accountOf(account); + final dirCache = >{}; + await _fillDirCacheForDir( + db, dirCache, dbAccount, null, dir.fileId, dir.strippedPathWithEmpty); + _log.info( + "[_cacheDir] Cached ${dirCache.length} dirs under ${logFilename(dir.path)}"); + await _fillFileCache( + db, account, dbAccount, dirCache.values.flatten().toSet()); + _dirCache.addAll(dirCache); + }); + } + + Future _fillDirCacheForDir( + sql.SqliteDb db, + Map> dirCache, + sql.Account dbAccount, + int? dirRowId, + int? dirFileId, + String dirRelativePath) async { + // get rowId + final myDirRowId = dirRowId ?? + await _queryFileIdOfDir(db, dbAccount, dirFileId, dirRelativePath); + if (myDirRowId == null) { // no cache return; } - final dirs = dirItems - .map((i) => AppDbDirEntry.fromJson(i.cast())) - .toList(); - _dirCache.addEntries(dirs.map( - (e) => MapEntry(AppDbDirEntry.toPrimaryKeyForDir(account, e.dir), e))); - _log.info( - "[_cacheDir] Cached ${dirs.length} dirs under ${logFilename(dir.path)}"); - - // cache files - final fileIds = dirs.map((e) => e.children).fold>( - [], (previousValue, element) => previousValue + element); - - final needQuery = []; - final files = []; - // check files already known to us - if (knownFiles.isNotEmpty) { - for (final id in fileIds) { - final f = knownFiles[id]; - if (f != null) { - files.add(f); - } else { - needQuery.add(id); - } - } - } else { - needQuery.addAll(fileIds); + final children = await _queryChildOfDir(db, dbAccount, myDirRowId); + if (children.isEmpty) { + // no cache + return; } - _log.info( - "[_cacheDir] ${files.length} files known, ${needQuery.length} files need querying"); + final childFileIds = children.map((c) => c.fileId).toList(); + dirCache[dirRelativePath] = childFileIds; - // query other files + // recursively fill child dirs + for (final c in children + .where((c) => c.rowId != myDirRowId && c.isCollection == true)) { + await _fillDirCacheForDir( + db, dirCache, dbAccount, c.rowId, c.fileId, c.relativePath); + } + } + + Future _fillFileCache(sql.SqliteDb db, Account account, + sql.Account dbAccount, Iterable fileIds) async { + final needQuery = fileIds.where((id) => !_fileCache.containsKey(id)); if (needQuery.isNotEmpty) { - final dbItems = await appDb.use( - (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), - (transaction) async { - final store = transaction.objectStore(AppDb.file2StoreName); - return await Future.wait(needQuery.map((id) => - store.getObject(AppDbFile2Entry.toPrimaryKey(account, id)))); - }, - ); - files.addAll( - await dbItems.whereType().computeAll(_covertAppDbFile2Entry)); + _log.info("[_fillFileCache] ${needQuery.length} files need querying"); + final dbFiles = + await db.completeFilesByFileIds(needQuery, sqlAccount: dbAccount); + for (final f in await dbFiles.convertToAppFile(account)) { + _fileCache[f.fileId!] = f; + } + _log.info("[_fillFileCache] Cached ${dbFiles.length} files"); + } else { + _log.info("[_fillFileCache] 0 files need querying"); } - _fileCache.addEntries(files.map((f) => MapEntry(f.fileId!, f))); - _log.info( - "[_cacheDir] Cached ${files.length} files under ${logFilename(dir.path)}"); } - List _withDirEntry(AppDbDirEntry dirEntry) { - return [dirEntry.dir] + - dirEntry.children.map((id) { - try { - return _fileCache[id]!; - } catch (_) { - _log.warning( - "[list] Missing file ($id) in db for dir: ${logFilename(dirEntry.dir.path)}"); - throw CacheNotFoundException("No entry for dir child: $id"); - } - }).toList(); + List _listByFileIds(File dir, List childFileIds) { + return childFileIds.map((id) { + try { + return _fileCache[id]!; + } catch (_) { + _log.warning( + "[_listByFileIds] Missing file ($id) in db for dir: ${logFilename(dir.path)}"); + throw CacheNotFoundException("No entry for dir child: $id"); + } + }).toList(); } - final AppDb appDb; - final Map knownFiles; - final _dirCache = {}; + Future _queryFileIdOfDir(sql.SqliteDb db, sql.Account dbAccount, + int? dirFileId, String dirRelativePath) async { + final dirQuery = db.queryFiles().run((q) { + q + ..setQueryMode(sql.FilesQueryMode.expression, + expressions: [db.files.rowId]) + ..setSqlAccount(dbAccount); + if (dirFileId != null) { + q.byFileId(dirFileId); + } else { + q.byRelativePath(dirRelativePath); + } + return q.build()..limit(1); + }); + return await dirQuery.map((r) => r.read(db.files.rowId)).getSingleOrNull(); + } + + Future> _queryChildOfDir( + sql.SqliteDb db, sql.Account dbAccount, int dirRowId) async { + final childQuery = db.selectOnly(db.files).join([ + sql.innerJoin(db.dirFiles, db.dirFiles.child.equalsExp(db.files.rowId), + useColumns: false), + sql.innerJoin( + db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId), + useColumns: false), + ]) + ..addColumns([ + db.files.rowId, + db.files.fileId, + db.files.isCollection, + db.accountFiles.relativePath, + ]) + ..where(db.dirFiles.dir.equals(dirRowId)) + ..where(db.accountFiles.account.equals(dbAccount.rowId)); + return await childQuery + .map((r) => _ForwardCacheQueryChildResult( + r.read(db.files.rowId)!, + r.read(db.files.fileId)!, + r.read(db.accountFiles.relativePath)!, + r.read(db.files.isCollection), + )) + .get(); + } + + final DiContainer _c; + final _dirCache = >{}; final _fileCache = {}; static final _log = Logger("entity.file.data_source.FileForwardCacheManager"); } +class _ForwardCacheQueryChildResult { + const _ForwardCacheQueryChildResult( + this.rowId, this.fileId, this.relativePath, this.isCollection); + + final int rowId; + final int fileId; + final String relativePath; + final bool? isCollection; +} + bool _validateFile(File f) { // See: https://gitlab.com/nkming2/nc-photos/-/issues/9 return f.lastModified != null; } - -File _covertAppDbFile2Entry(Map json) => - AppDbFile2Entry.fromJson(json.cast()).file; diff --git a/app/lib/entity/file/file_cache_manager.dart b/app/lib/entity/file/file_cache_manager.dart index dcd02ef3..1c522a11 100644 --- a/app/lib/entity/file/file_cache_manager.dart +++ b/app/lib/entity/file/file_cache_manager.dart @@ -1,25 +1,28 @@ -import 'package:collection/collection.dart'; -import 'package:idb_shim/idb_client.dart'; +import 'package:drift/drift.dart' as sql; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; -import 'package:nc_photos/debug_util.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/sqlite_table.dart' as sql; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/list_util.dart' as list_util; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util; import 'package:nc_photos/touch_token_manager.dart'; class FileCacheLoader { - FileCacheLoader({ - required this.appDb, - required this.appDbSrc, + FileCacheLoader( + this._c, { + required this.cacheSrc, required this.remoteSrc, this.shouldCheckCache = false, this.forwardCacheManager, - }); + }) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo); /// Return the cached results of listing a directory [dir] /// @@ -30,7 +33,7 @@ class FileCacheLoader { if (forwardCacheManager != null) { cache = await forwardCacheManager!.list(account, dir); } else { - cache = await appDbSrc.list(account, dir); + cache = await cacheSrc.list(account, dir); } // compare the cached root final cacheEtag = @@ -40,8 +43,6 @@ class FileCacheLoader { // if no etag supplied, we need to query it form remote remoteEtag ??= (await remoteSrc.list(account, dir, depth: 0)).first.etag; if (cacheEtag == remoteEtag) { - _log.fine( - "[list] etag matched for ${AppDbDirEntry.toPrimaryKeyForDir(account, dir)}"); if (shouldCheckCache) { await _checkTouchToken(account, dir, cache); } else { @@ -65,11 +66,10 @@ class FileCacheLoader { Account account, File f, List cache) async { final touchPath = "${remote_storage_util.getRemoteTouchDir(account)}/${f.strippedPath}"; - final fileRepo = FileRepo(FileCachedDataSource(appDb)); const tokenManager = TouchTokenManager(); String? remoteToken; try { - remoteToken = await tokenManager.getRemoteToken(fileRepo, account, f); + remoteToken = await tokenManager.getRemoteToken(_c.fileRepo, account, f); } catch (e, stacktrace) { _log.shout( "[_checkTouchToken] Failed getting remote token at '$touchPath'", @@ -96,9 +96,9 @@ class FileCacheLoader { } } - final AppDb appDb; + final DiContainer _c; final FileWebdavDataSource remoteSrc; - final FileAppDbDataSource appDbSrc; + final FileDataSource cacheSrc; final bool shouldCheckCache; final FileForwardCacheManager? forwardCacheManager; @@ -108,215 +108,264 @@ class FileCacheLoader { static final _log = Logger("entity.file.file_cache_manager.FileCacheLoader"); } -class FileCacheUpdater { - const FileCacheUpdater(this.appDb); +class FileSqliteCacheUpdater { + FileSqliteCacheUpdater(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb); Future call( Account account, File dir, { required List remote, - List? cache, }) async { - await _cacheRemote(account, dir, remote); - if (cache != null) { - await _cleanUpCache(account, remote, cache); + final s = Stopwatch()..start(); + try { + await _cacheRemote(account, dir, remote); + } finally { + _log.info("[call] Elapsed time: ${s.elapsedMilliseconds}ms"); } } Future _cacheRemote( Account account, File dir, List remote) async { - final s = Stopwatch()..start(); - try { - await appDb.use( - (db) => db.transaction( - [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite), - (transaction) async { - final dirStore = transaction.objectStore(AppDb.dirStoreName); - final fileStore = transaction.objectStore(AppDb.file2StoreName); + final sqlFiles = await remote.convertToFileCompanion(null); + await _c.sqliteDb.use((db) async { + final dbAccount = await db.accountOf(account); + final inserts = await _updateCache(db, dbAccount, sqlFiles, remote, dir); + if (inserts.isNotEmpty) { + await _insertCache(db, dbAccount, inserts, dir); + } + if (_dirRowId == null) { + _log.severe("[_cacheRemote] Dir not inserted"); + throw StateError("Row ID for dir is null"); + } - // add files to db - await Future.wait(remote.map((f) => fileStore.put( - AppDbFile2Entry.fromFile(account, f).toJson(), - AppDbFile2Entry.toPrimaryKeyForFile(account, f)))); - - // results from remote also contain the dir itself - final resultGroup = - remote.groupListsBy((f) => f.compareServerIdentity(dir)); - final remoteDir = resultGroup[true]!.first; - final remoteChildren = resultGroup[false] ?? []; - // add dir to db - await dirStore.put( - AppDbDirEntry.fromFiles(account, remoteDir, remoteChildren) - .toJson(), - AppDbDirEntry.toPrimaryKeyForDir(account, remoteDir)); - }, - ); - } finally { - _log.info("[_cacheRemote] Elapsed time: ${s.elapsedMilliseconds}ms"); - } + final dirChildRowIdQuery = db.selectOnly(db.dirFiles) + ..addColumns([db.dirFiles.child]) + ..where(db.dirFiles.dir.equals(_dirRowId)) + ..orderBy([sql.OrderingTerm.asc(db.dirFiles.rowId)]); + final dirChildRowIds = + await dirChildRowIdQuery.map((r) => r.read(db.dirFiles.child)!).get(); + final diff = list_util.diff(dirChildRowIds, _childRowIds.sorted()); + if (diff.item1.isNotEmpty) { + await db.batch((batch) { + // insert new children + batch.insertAll(db.dirFiles, + diff.item1.map((k) => sql.DirFile(dir: _dirRowId!, child: k))); + }); + } + if (diff.item2.isNotEmpty) { + // delete obsolete children + await _removeSqliteFiles(db, dbAccount, diff.item2); + await _cleanUpRemovedFile(db, dbAccount); + } + }); } - /// Remove extra entries from local cache based on remote contents - Future _cleanUpCache( - Account account, List remote, List cache) async { - final removed = cache - .where((c) => !remote.any((r) => r.compareServerIdentity(c))) - .toList(); - if (removed.isEmpty) { - return; - } - _log.info( - "[_cleanUpCache] Removed: ${removed.map((f) => f.path).toReadableString()}"); + /// Update Db files in [sqlFiles] + /// + /// Return a list of DB files that are not yet inserted to the DB (thus not + /// possible to update) + Future> _updateCache( + sql.SqliteDb db, + sql.Account dbAccount, + Iterable sqlFiles, + Iterable remoteFiles, + File dir, + ) async { + // query list of rowIds for files in [remoteFiles] + final rowIds = await db.accountFileRowIdsByFileIds( + remoteFiles.map((f) => f.fileId!), + sqlAccount: dbAccount, + ); + final rowIdsMap = Map.fromEntries(rowIds.map((e) => MapEntry(e.fileId, e))); - await appDb.use( - (db) => db.transaction( - [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite), - (transaction) async { - final dirStore = transaction.objectStore(AppDb.dirStoreName); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - for (final f in removed) { - try { - if (f.isCollection == true) { - await _removeDirFromAppDb(account, f, - dirStore: dirStore, fileStore: fileStore); - } else { - await _removeFileFromAppDb(account, f, fileStore: fileStore); - } - } catch (e, stackTrace) { - _log.shout( - "[_cleanUpCache] Failed while removing file: ${logFilename(f.path)}", - e, - stackTrace); + final inserts = []; + // for updates, we use batch to speed up the process + await db.batch((batch) { + for (final f in sqlFiles) { + final thisRowIds = rowIdsMap[f.file.fileId.value]; + if (thisRowIds != null) { + // updates + batch.update( + db.files, + f.file, + where: (sql.$FilesTable t) => t.rowId.equals(thisRowIds.fileRowId), + ); + batch.update( + db.accountFiles, + f.accountFile, + where: (sql.$AccountFilesTable t) => + t.rowId.equals(thisRowIds.accountFileRowId), + ); + if (f.image != null) { + batch.update( + db.images, + f.image!, + where: (sql.$ImagesTable t) => + t.accountFile.equals(thisRowIds.accountFileRowId), + ); } + if (f.trash != null) { + batch.update( + db.trashes, + f.trash!, + where: (sql.$TrashesTable t) => + t.file.equals(thisRowIds.fileRowId), + ); + } + _onRowCached(thisRowIds.fileRowId, f, dir); + } else { + // inserts, do it later + inserts.add(f); } - }, + } + }); + _log.info( + "[_updateCache] Updated ${sqlFiles.length - inserts.length} files"); + return inserts; + } + + Future _insertCache(sql.SqliteDb db, sql.Account dbAccount, + List sqlFiles, File dir) async { + _log.info("[_insertCache] Insert ${sqlFiles.length} files"); + // check if the files exist in the db in other accounts + final query = db.queryFiles().run((q) { + q + ..setQueryMode( + sql.FilesQueryMode.expression, + expressions: [db.files.rowId, db.files.fileId], + ) + ..setAccountless() + ..byServerRowId(dbAccount.server) + ..byFileIds(sqlFiles.map((f) => f.file.fileId.value)); + return q.build(); + }); + final fileRowIdMap = Map.fromEntries(await query + .map((r) => MapEntry(r.read(db.files.fileId)!, r.read(db.files.rowId)!)) + .get()); + + await Future.wait( + sqlFiles.map((f) async { + var rowId = fileRowIdMap[f.file.fileId.value]; + if (rowId != null) { + // shared file that exists in other accounts + } else { + final dbFile = await db.into(db.files).insertReturning( + f.file.copyWith(server: sql.Value(dbAccount.server)), + ); + rowId = dbFile.rowId; + } + final dbAccountFile = await db + .into(db.accountFiles) + .insertReturning(f.accountFile.copyWith( + account: sql.Value(dbAccount.rowId), + file: sql.Value(rowId), + )); + if (f.image != null) { + await db.into(db.images).insert( + f.image!.copyWith(accountFile: sql.Value(dbAccountFile.rowId))); + } + if (f.trash != null) { + await db + .into(db.trashes) + .insert(f.trash!.copyWith(file: sql.Value(rowId))); + } + _onRowCached(rowId, f, dir); + }), + eagerError: true, ); } - final AppDb appDb; + void _onRowCached(int rowId, sql.CompleteFileCompanion dbFile, File dir) { + if (_compareIdentity(dbFile, dir)) { + _dirRowId = rowId; + } + _childRowIds.add(rowId); + } - static final _log = Logger("entity.file.file_cache_manager.FileCacheUpdater"); + bool _compareIdentity(sql.CompleteFileCompanion dbFile, File appFile) { + if (appFile.fileId != null) { + return appFile.fileId == dbFile.file.fileId.value; + } else { + return appFile.strippedPathWithEmpty == + dbFile.accountFile.relativePath.value; + } + } + + final DiContainer _c; + + int? _dirRowId; + final _childRowIds = []; + + static final _log = + Logger("entity.file.file_cache_manager.FileSqliteCacheUpdater"); } -class FileCacheRemover { - const FileCacheRemover(this.appDb); +class FileSqliteCacheRemover { + FileSqliteCacheRemover(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb); /// Remove a file/dir from cache - /// - /// If [f] is a dir, the dir and its sub-dirs will be removed from dirStore. - /// The files inside any of these dirs will be removed from file2Store. - /// - /// If [f] is a file, the file will be removed from file2Store, but no changes - /// to dirStore. Future call(Account account, File f) async { - if (f.isCollection != false) { - // removing dir is basically a superset of removing file, so we'll treat - // unspecified file as dir - await appDb.use( - (db) => db.transaction( - [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite), - (transaction) async { - final dirStore = transaction.objectStore(AppDb.dirStoreName); - final fileStore = transaction.objectStore(AppDb.file2StoreName); - await _removeDirFromAppDb(account, f, - dirStore: dirStore, fileStore: fileStore); - }, - ); - } else { - await appDb.use( - (db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite), - (transaction) async { - final fileStore = transaction.objectStore(AppDb.file2StoreName); - await _removeFileFromAppDb(account, f, fileStore: fileStore); - }, - ); - } - } - - final AppDb appDb; -} - -Future _removeFileFromAppDb( - Account account, - File file, { - required ObjectStore fileStore, -}) async { - try { - if (file.fileId == null) { - final index = fileStore.index(AppDbFile2Entry.strippedPathIndexName); - final key = await index - .getKey(AppDbFile2Entry.toStrippedPathIndexKeyForFile(account, file)); - if (key != null) { - _log.fine("[_removeFileFromAppDb] Removing fileStore entry: $key"); - await fileStore.delete(key); - } - } else { - await AppDbFile2Entry.toPrimaryKeyForFile(account, file).run((key) { - _log.fine("[_removeFileFromAppDb] Removing fileStore entry: $key"); - return fileStore.delete(key); - }); - } - } catch (e, stackTrace) { - _log.shout( - "[_removeFileFromAppDb] Failed removing fileStore entry: ${logFilename(file.path)}", - e, - stackTrace); - } -} - -/// Remove a dir and all files inside from the database -Future _removeDirFromAppDb( - Account account, - File dir, { - required ObjectStore dirStore, - required ObjectStore fileStore, -}) async { - // delete the dir itself - try { - await AppDbDirEntry.toPrimaryKeyForDir(account, dir).run((key) { - _log.fine("[_removeDirFromAppDb] Removing dirStore entry: $key"); - return dirStore.delete(key); + await _c.sqliteDb.use((db) async { + final dbAccount = await db.accountOf(account); + final rowIds = await db.accountFileRowIdsOf(f, sqlAccount: dbAccount); + await _removeSqliteFiles(db, dbAccount, [rowIds.fileRowId]); + await _cleanUpRemovedFile(db, dbAccount); }); - } catch (e, stackTrace) { - if (dir.isCollection != null) { - _log.shout("[_removeDirFromAppDb] Failed removing dirStore entry", e, - stackTrace); - } - } - // then its children - final childrenRange = KeyRange.bound( - AppDbDirEntry.toPrimaryLowerKeyForSubDirs(account, dir), - AppDbDirEntry.toPrimaryUpperKeyForSubDirs(account, dir), - ); - for (final key in await dirStore.getAllKeys(childrenRange)) { - _log.fine("[_removeDirFromAppDb] Removing dirStore entry: $key"); - try { - await dirStore.delete(key); - } catch (e, stackTrace) { - _log.shout("[_removeDirFromAppDb] Failed removing dirStore entry", e, - stackTrace); - } } - // delete files from fileStore - // first the dir - await _removeFileFromAppDb(account, dir, fileStore: fileStore); - // then files under this dir and sub-dirs - final range = KeyRange.bound( - AppDbFile2Entry.toStrippedPathIndexLowerKeyForDir(account, dir), - AppDbFile2Entry.toStrippedPathIndexUpperKeyForDir(account, dir), - ); - final strippedPathIndex = - fileStore.index(AppDbFile2Entry.strippedPathIndexName); - for (final key in await strippedPathIndex.getAllKeys(range)) { - _log.fine("[_removeDirFromAppDb] Removing fileStore entry: $key"); - try { - await fileStore.delete(key); - } catch (e, stackTrace) { - _log.shout("[_removeDirFromAppDb] Failed removing fileStore entry", e, - stackTrace); - } + final DiContainer _c; +} + +/// Remove a files from the cache db +/// +/// If a file is a dir, its children will also be recursively removed +Future _removeSqliteFiles( + sql.SqliteDb db, sql.Account dbAccount, List fileRowIds) async { + // query list of children, in case some of the files are dirs + final childQuery = db.selectOnly(db.dirFiles) + ..addColumns([db.dirFiles.child]) + ..where(db.dirFiles.dir.isIn(fileRowIds)); + final childRowIds = + await childQuery.map((r) => r.read(db.dirFiles.child)!).get(); + childRowIds.removeWhere((id) => fileRowIds.contains(id)); + + // remove the files in AccountFiles table. We are not removing in Files table + // because a file could be associated with multiple accounts + await (db.delete(db.accountFiles) + ..where( + (t) => t.account.equals(dbAccount.rowId) & t.file.isIn(fileRowIds))) + .go(); + + if (childRowIds.isNotEmpty) { + // remove children recursively + return _removeSqliteFiles(db, dbAccount, childRowIds); + } else { + return; } } -final _log = Logger("entity.file.file_cache_manager"); +Future _cleanUpRemovedFile(sql.SqliteDb db, sql.Account dbAccount) async { + // delete dangling files: entries in Files w/o a corresponding entry in + // AccountFiles + final danglingFileQuery = db.selectOnly(db.files).join([ + sql.leftOuterJoin( + db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId), + useColumns: false), + ]) + ..addColumns([db.files.rowId]) + ..where(db.accountFiles.relativePath.isNull()); + final danglingFileRowIds = + await danglingFileQuery.map((r) => r.read(db.files.rowId)!).get(); + if (danglingFileRowIds.isNotEmpty) { + __log.info( + "[_cleanUpRemovedFile] Delete ${danglingFileRowIds.length} files"); + await (db.delete(db.files)..where((t) => t.rowId.isIn(danglingFileRowIds))) + .go(); + } +} + +final __log = Logger("entity.file.file_cache_manager"); diff --git a/app/lib/entity/sqlite_table.dart b/app/lib/entity/sqlite_table.dart new file mode 100644 index 00000000..010ae1cd --- /dev/null +++ b/app/lib/entity/sqlite_table.dart @@ -0,0 +1,209 @@ +import 'package:drift/drift.dart'; +import 'package:nc_photos/mobile/platform.dart' + if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform; + +part 'sqlite_table.g.dart'; + +class Servers extends Table { + IntColumn get rowId => integer().autoIncrement()(); + TextColumn get address => text().unique()(); +} + +class Accounts extends Table { + IntColumn get rowId => integer().autoIncrement()(); + IntColumn get server => + integer().references(Servers, #rowId, onDelete: KeyAction.cascade)(); + TextColumn get userId => text()(); + + @override + get uniqueKeys => [ + {server, userId}, + ]; +} + +/// A file located on a server +class Files extends Table { + IntColumn get rowId => integer().autoIncrement()(); + IntColumn get server => + integer().references(Servers, #rowId, onDelete: KeyAction.cascade)(); + IntColumn get fileId => integer()(); + IntColumn get contentLength => integer().nullable()(); + TextColumn get contentType => text().nullable()(); + TextColumn get etag => text().nullable()(); + DateTimeColumn get lastModified => + dateTime().map(const _DateTimeConverter()).nullable()(); + BoolColumn get isCollection => boolean().nullable()(); + IntColumn get usedBytes => integer().nullable()(); + BoolColumn get hasPreview => boolean().nullable()(); + TextColumn get ownerId => text().nullable()(); + + @override + get uniqueKeys => [ + {server, fileId}, + ]; +} + +/// Account specific properties associated with a file +/// +/// A file on a Nextcloud server can have more than 1 path when it's shared +class AccountFiles extends Table { + IntColumn get rowId => integer().autoIncrement()(); + IntColumn get account => + integer().references(Accounts, #rowId, onDelete: KeyAction.cascade)(); + IntColumn get file => + integer().references(Files, #rowId, onDelete: KeyAction.cascade)(); + TextColumn get relativePath => text()(); + BoolColumn get isFavorite => boolean().nullable()(); + BoolColumn get isArchived => boolean().nullable()(); + DateTimeColumn get overrideDateTime => + dateTime().map(const _DateTimeConverter()).nullable()(); + + @override + get uniqueKeys => [ + {account, file}, + ]; +} + +/// An image file +class Images extends Table { + // image data technically is identical between accounts, but the way it's + // stored in the server is account specific so we follow the server here + IntColumn get accountFile => + integer().references(AccountFiles, #rowId, onDelete: KeyAction.cascade)(); + DateTimeColumn get lastUpdated => + dateTime().map(const _DateTimeConverter())(); + TextColumn get fileEtag => text().nullable()(); + IntColumn get width => integer().nullable()(); + IntColumn get height => integer().nullable()(); + TextColumn get exifRaw => text().nullable()(); + + // exif columns + DateTimeColumn get dateTimeOriginal => + dateTime().map(const _DateTimeConverter()).nullable()(); + + @override + get primaryKey => {accountFile}; +} + +/// A file inside trashbin +@DataClassName("Trash") +class Trashes extends Table { + IntColumn get file => + integer().references(Files, #rowId, onDelete: KeyAction.cascade)(); + TextColumn get filename => text()(); + TextColumn get originalLocation => text()(); + DateTimeColumn get deletionTime => + dateTime().map(const _DateTimeConverter())(); + + @override + get primaryKey => {file}; +} + +/// A file located under another dir (dir is also a file) +class DirFiles extends Table { + IntColumn get dir => + integer().references(Files, #rowId, onDelete: KeyAction.cascade)(); + IntColumn get child => + integer().references(Files, #rowId, onDelete: KeyAction.cascade)(); + + @override + get primaryKey => {dir, child}; +} + +class Albums extends Table { + IntColumn get rowId => integer().autoIncrement()(); + IntColumn get file => integer() + .references(Files, #rowId, onDelete: KeyAction.cascade) + .unique()(); + IntColumn get version => integer()(); + DateTimeColumn get lastUpdated => + dateTime().map(const _DateTimeConverter())(); + TextColumn get name => text()(); + + // provider + TextColumn get providerType => text()(); + TextColumn get providerContent => text()(); + + // cover provider + TextColumn get coverProviderType => text()(); + TextColumn get coverProviderContent => text()(); + + // sort provider + TextColumn get sortProviderType => text()(); + TextColumn get sortProviderContent => text()(); +} + +class AlbumShares extends Table { + IntColumn get album => + integer().references(Albums, #rowId, onDelete: KeyAction.cascade)(); + TextColumn get userId => text()(); + TextColumn get displayName => text().nullable()(); + DateTimeColumn get sharedAt => dateTime().map(const _DateTimeConverter())(); + + @override + get primaryKey => {album, userId}; +} + +@DriftDatabase( + tables: [ + Servers, + Accounts, + Files, + Images, + Trashes, + AccountFiles, + DirFiles, + Albums, + AlbumShares, + ], +) +class SqliteDb extends _$SqliteDb { + SqliteDb({ + QueryExecutor? executor, + }) : super(executor ?? platform.openSqliteConnection()); + + SqliteDb.connect(DatabaseConnection connection) : super.connect(connection); + + @override + get schemaVersion => 1; + + @override + get migration => MigrationStrategy( + onCreate: (m) async { + await m.createAll(); + + await m.createIndex(Index("files_server_index", + "CREATE INDEX files_server_index ON files(server);")); + await m.createIndex(Index("files_file_id_index", + "CREATE INDEX files_file_id_index ON files(file_id);")); + await m.createIndex(Index("files_content_type_index", + "CREATE INDEX files_content_type_index ON files(content_type);")); + + await m.createIndex(Index("account_files_file_index", + "CREATE INDEX account_files_file_index ON account_files(file);")); + await m.createIndex(Index("account_files_relative_path_index", + "CREATE INDEX account_files_relative_path_index ON account_files(relative_path);")); + + await m.createIndex(Index("dir_files_dir_index", + "CREATE INDEX dir_files_dir_index ON dir_files(dir);")); + await m.createIndex(Index("dir_files_child_index", + "CREATE INDEX dir_files_child_index ON dir_files(child);")); + + await m.createIndex(Index("album_shares_album_index", + "CREATE INDEX album_shares_album_index ON album_shares(album);")); + }, + beforeOpen: (details) async { + await customStatement("PRAGMA foreign_keys = ON"); + }, + ); +} + +class _DateTimeConverter extends TypeConverter { + const _DateTimeConverter(); + + @override + DateTime? mapToDart(DateTime? fromDb) => fromDb?.toUtc(); + + @override + DateTime? mapToSql(DateTime? value) => value?.toUtc(); +} diff --git a/app/lib/entity/sqlite_table.g.dart b/app/lib/entity/sqlite_table.g.dart new file mode 100644 index 00000000..a16991bb --- /dev/null +++ b/app/lib/entity/sqlite_table.g.dart @@ -0,0 +1,3013 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sqlite_table.dart'; + +// ************************************************************************** +// MoorGenerator +// ************************************************************************** + +// ignore_for_file: type=lint +class Server extends DataClass implements Insertable { + final int rowId; + final String address; + Server({required this.rowId, required this.address}); + factory Server.fromData(Map data, {String? prefix}) { + final effectivePrefix = prefix ?? ''; + return Server( + rowId: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}row_id'])!, + address: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}address'])!, + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['row_id'] = Variable(rowId); + map['address'] = Variable(address); + return map; + } + + ServersCompanion toCompanion(bool nullToAbsent) { + return ServersCompanion( + rowId: Value(rowId), + address: Value(address), + ); + } + + factory Server.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Server( + rowId: serializer.fromJson(json['rowId']), + address: serializer.fromJson(json['address']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'rowId': serializer.toJson(rowId), + 'address': serializer.toJson(address), + }; + } + + Server copyWith({int? rowId, String? address}) => Server( + rowId: rowId ?? this.rowId, + address: address ?? this.address, + ); + @override + String toString() { + return (StringBuffer('Server(') + ..write('rowId: $rowId, ') + ..write('address: $address') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(rowId, address); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Server && + other.rowId == this.rowId && + other.address == this.address); +} + +class ServersCompanion extends UpdateCompanion { + final Value rowId; + final Value address; + const ServersCompanion({ + this.rowId = const Value.absent(), + this.address = const Value.absent(), + }); + ServersCompanion.insert({ + this.rowId = const Value.absent(), + required String address, + }) : address = Value(address); + static Insertable custom({ + Expression? rowId, + Expression? address, + }) { + return RawValuesInsertable({ + if (rowId != null) 'row_id': rowId, + if (address != null) 'address': address, + }); + } + + ServersCompanion copyWith({Value? rowId, Value? address}) { + return ServersCompanion( + rowId: rowId ?? this.rowId, + address: address ?? this.address, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (rowId.present) { + map['row_id'] = Variable(rowId.value); + } + if (address.present) { + map['address'] = Variable(address.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ServersCompanion(') + ..write('rowId: $rowId, ') + ..write('address: $address') + ..write(')')) + .toString(); + } +} + +class $ServersTable extends Servers with TableInfo<$ServersTable, Server> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ServersTable(this.attachedDatabase, [this._alias]); + final VerificationMeta _rowIdMeta = const VerificationMeta('rowId'); + @override + late final GeneratedColumn rowId = GeneratedColumn( + 'row_id', aliasedName, false, + type: const IntType(), + requiredDuringInsert: false, + defaultConstraints: 'PRIMARY KEY AUTOINCREMENT'); + final VerificationMeta _addressMeta = const VerificationMeta('address'); + @override + late final GeneratedColumn address = GeneratedColumn( + 'address', aliasedName, false, + type: const StringType(), + requiredDuringInsert: true, + defaultConstraints: 'UNIQUE'); + @override + List get $columns => [rowId, address]; + @override + String get aliasedName => _alias ?? 'servers'; + @override + String get actualTableName => 'servers'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('row_id')) { + context.handle( + _rowIdMeta, rowId.isAcceptableOrUnknown(data['row_id']!, _rowIdMeta)); + } + if (data.containsKey('address')) { + context.handle(_addressMeta, + address.isAcceptableOrUnknown(data['address']!, _addressMeta)); + } else if (isInserting) { + context.missing(_addressMeta); + } + return context; + } + + @override + Set get $primaryKey => {rowId}; + @override + Server map(Map data, {String? tablePrefix}) { + return Server.fromData(data, + prefix: tablePrefix != null ? '$tablePrefix.' : null); + } + + @override + $ServersTable createAlias(String alias) { + return $ServersTable(attachedDatabase, alias); + } +} + +class Account extends DataClass implements Insertable { + final int rowId; + final int server; + final String userId; + Account({required this.rowId, required this.server, required this.userId}); + factory Account.fromData(Map data, {String? prefix}) { + final effectivePrefix = prefix ?? ''; + return Account( + rowId: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}row_id'])!, + server: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}server'])!, + userId: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}user_id'])!, + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['row_id'] = Variable(rowId); + map['server'] = Variable(server); + map['user_id'] = Variable(userId); + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + rowId: Value(rowId), + server: Value(server), + userId: Value(userId), + ); + } + + factory Account.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Account( + rowId: serializer.fromJson(json['rowId']), + server: serializer.fromJson(json['server']), + userId: serializer.fromJson(json['userId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'rowId': serializer.toJson(rowId), + 'server': serializer.toJson(server), + 'userId': serializer.toJson(userId), + }; + } + + Account copyWith({int? rowId, int? server, String? userId}) => Account( + rowId: rowId ?? this.rowId, + server: server ?? this.server, + userId: userId ?? this.userId, + ); + @override + String toString() { + return (StringBuffer('Account(') + ..write('rowId: $rowId, ') + ..write('server: $server, ') + ..write('userId: $userId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(rowId, server, userId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Account && + other.rowId == this.rowId && + other.server == this.server && + other.userId == this.userId); +} + +class AccountsCompanion extends UpdateCompanion { + final Value rowId; + final Value server; + final Value userId; + const AccountsCompanion({ + this.rowId = const Value.absent(), + this.server = const Value.absent(), + this.userId = const Value.absent(), + }); + AccountsCompanion.insert({ + this.rowId = const Value.absent(), + required int server, + required String userId, + }) : server = Value(server), + userId = Value(userId); + static Insertable custom({ + Expression? rowId, + Expression? server, + Expression? userId, + }) { + return RawValuesInsertable({ + if (rowId != null) 'row_id': rowId, + if (server != null) 'server': server, + if (userId != null) 'user_id': userId, + }); + } + + AccountsCompanion copyWith( + {Value? rowId, Value? server, Value? userId}) { + return AccountsCompanion( + rowId: rowId ?? this.rowId, + server: server ?? this.server, + userId: userId ?? this.userId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (rowId.present) { + map['row_id'] = Variable(rowId.value); + } + if (server.present) { + map['server'] = Variable(server.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('rowId: $rowId, ') + ..write('server: $server, ') + ..write('userId: $userId') + ..write(')')) + .toString(); + } +} + +class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AccountsTable(this.attachedDatabase, [this._alias]); + final VerificationMeta _rowIdMeta = const VerificationMeta('rowId'); + @override + late final GeneratedColumn rowId = GeneratedColumn( + 'row_id', aliasedName, false, + type: const IntType(), + requiredDuringInsert: false, + defaultConstraints: 'PRIMARY KEY AUTOINCREMENT'); + final VerificationMeta _serverMeta = const VerificationMeta('server'); + @override + late final GeneratedColumn server = GeneratedColumn( + 'server', aliasedName, false, + type: const IntType(), + requiredDuringInsert: true, + defaultConstraints: 'REFERENCES servers (row_id) ON DELETE CASCADE'); + final VerificationMeta _userIdMeta = const VerificationMeta('userId'); + @override + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + @override + List get $columns => [rowId, server, userId]; + @override + String get aliasedName => _alias ?? 'accounts'; + @override + String get actualTableName => 'accounts'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('row_id')) { + context.handle( + _rowIdMeta, rowId.isAcceptableOrUnknown(data['row_id']!, _rowIdMeta)); + } + if (data.containsKey('server')) { + context.handle(_serverMeta, + server.isAcceptableOrUnknown(data['server']!, _serverMeta)); + } else if (isInserting) { + context.missing(_serverMeta); + } + if (data.containsKey('user_id')) { + context.handle(_userIdMeta, + userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); + } else if (isInserting) { + context.missing(_userIdMeta); + } + return context; + } + + @override + Set get $primaryKey => {rowId}; + @override + List> get uniqueKeys => [ + {server, userId}, + ]; + @override + Account map(Map data, {String? tablePrefix}) { + return Account.fromData(data, + prefix: tablePrefix != null ? '$tablePrefix.' : null); + } + + @override + $AccountsTable createAlias(String alias) { + return $AccountsTable(attachedDatabase, alias); + } +} + +class File extends DataClass implements Insertable { + final int rowId; + final int server; + final int fileId; + final int? contentLength; + final String? contentType; + final String? etag; + final DateTime? lastModified; + final bool? isCollection; + final int? usedBytes; + final bool? hasPreview; + final String? ownerId; + File( + {required this.rowId, + required this.server, + required this.fileId, + this.contentLength, + this.contentType, + this.etag, + this.lastModified, + this.isCollection, + this.usedBytes, + this.hasPreview, + this.ownerId}); + factory File.fromData(Map data, {String? prefix}) { + final effectivePrefix = prefix ?? ''; + return File( + rowId: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}row_id'])!, + server: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}server'])!, + fileId: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}file_id'])!, + contentLength: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}content_length']), + contentType: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}content_type']), + etag: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}etag']), + lastModified: $FilesTable.$converter0.mapToDart(const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}last_modified'])), + isCollection: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}is_collection']), + usedBytes: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}used_bytes']), + hasPreview: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}has_preview']), + ownerId: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}owner_id']), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['row_id'] = Variable(rowId); + map['server'] = Variable(server); + map['file_id'] = Variable(fileId); + if (!nullToAbsent || contentLength != null) { + map['content_length'] = Variable(contentLength); + } + if (!nullToAbsent || contentType != null) { + map['content_type'] = Variable(contentType); + } + if (!nullToAbsent || etag != null) { + map['etag'] = Variable(etag); + } + if (!nullToAbsent || lastModified != null) { + final converter = $FilesTable.$converter0; + map['last_modified'] = + Variable(converter.mapToSql(lastModified)); + } + if (!nullToAbsent || isCollection != null) { + map['is_collection'] = Variable(isCollection); + } + if (!nullToAbsent || usedBytes != null) { + map['used_bytes'] = Variable(usedBytes); + } + if (!nullToAbsent || hasPreview != null) { + map['has_preview'] = Variable(hasPreview); + } + if (!nullToAbsent || ownerId != null) { + map['owner_id'] = Variable(ownerId); + } + return map; + } + + FilesCompanion toCompanion(bool nullToAbsent) { + return FilesCompanion( + rowId: Value(rowId), + server: Value(server), + fileId: Value(fileId), + contentLength: contentLength == null && nullToAbsent + ? const Value.absent() + : Value(contentLength), + contentType: contentType == null && nullToAbsent + ? const Value.absent() + : Value(contentType), + etag: etag == null && nullToAbsent ? const Value.absent() : Value(etag), + lastModified: lastModified == null && nullToAbsent + ? const Value.absent() + : Value(lastModified), + isCollection: isCollection == null && nullToAbsent + ? const Value.absent() + : Value(isCollection), + usedBytes: usedBytes == null && nullToAbsent + ? const Value.absent() + : Value(usedBytes), + hasPreview: hasPreview == null && nullToAbsent + ? const Value.absent() + : Value(hasPreview), + ownerId: ownerId == null && nullToAbsent + ? const Value.absent() + : Value(ownerId), + ); + } + + factory File.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return File( + rowId: serializer.fromJson(json['rowId']), + server: serializer.fromJson(json['server']), + fileId: serializer.fromJson(json['fileId']), + contentLength: serializer.fromJson(json['contentLength']), + contentType: serializer.fromJson(json['contentType']), + etag: serializer.fromJson(json['etag']), + lastModified: serializer.fromJson(json['lastModified']), + isCollection: serializer.fromJson(json['isCollection']), + usedBytes: serializer.fromJson(json['usedBytes']), + hasPreview: serializer.fromJson(json['hasPreview']), + ownerId: serializer.fromJson(json['ownerId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'rowId': serializer.toJson(rowId), + 'server': serializer.toJson(server), + 'fileId': serializer.toJson(fileId), + 'contentLength': serializer.toJson(contentLength), + 'contentType': serializer.toJson(contentType), + 'etag': serializer.toJson(etag), + 'lastModified': serializer.toJson(lastModified), + 'isCollection': serializer.toJson(isCollection), + 'usedBytes': serializer.toJson(usedBytes), + 'hasPreview': serializer.toJson(hasPreview), + 'ownerId': serializer.toJson(ownerId), + }; + } + + File copyWith( + {int? rowId, + int? server, + int? fileId, + Value contentLength = const Value.absent(), + Value contentType = const Value.absent(), + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value isCollection = const Value.absent(), + Value usedBytes = const Value.absent(), + Value hasPreview = const Value.absent(), + Value ownerId = const Value.absent()}) => + File( + rowId: rowId ?? this.rowId, + server: server ?? this.server, + fileId: fileId ?? this.fileId, + contentLength: + contentLength.present ? contentLength.value : this.contentLength, + contentType: contentType.present ? contentType.value : this.contentType, + etag: etag.present ? etag.value : this.etag, + lastModified: + lastModified.present ? lastModified.value : this.lastModified, + isCollection: + isCollection.present ? isCollection.value : this.isCollection, + usedBytes: usedBytes.present ? usedBytes.value : this.usedBytes, + hasPreview: hasPreview.present ? hasPreview.value : this.hasPreview, + ownerId: ownerId.present ? ownerId.value : this.ownerId, + ); + @override + String toString() { + return (StringBuffer('File(') + ..write('rowId: $rowId, ') + ..write('server: $server, ') + ..write('fileId: $fileId, ') + ..write('contentLength: $contentLength, ') + ..write('contentType: $contentType, ') + ..write('etag: $etag, ') + ..write('lastModified: $lastModified, ') + ..write('isCollection: $isCollection, ') + ..write('usedBytes: $usedBytes, ') + ..write('hasPreview: $hasPreview, ') + ..write('ownerId: $ownerId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + rowId, + server, + fileId, + contentLength, + contentType, + etag, + lastModified, + isCollection, + usedBytes, + hasPreview, + ownerId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is File && + other.rowId == this.rowId && + other.server == this.server && + other.fileId == this.fileId && + other.contentLength == this.contentLength && + other.contentType == this.contentType && + other.etag == this.etag && + other.lastModified == this.lastModified && + other.isCollection == this.isCollection && + other.usedBytes == this.usedBytes && + other.hasPreview == this.hasPreview && + other.ownerId == this.ownerId); +} + +class FilesCompanion extends UpdateCompanion { + final Value rowId; + final Value server; + final Value fileId; + final Value contentLength; + final Value contentType; + final Value etag; + final Value lastModified; + final Value isCollection; + final Value usedBytes; + final Value hasPreview; + final Value ownerId; + const FilesCompanion({ + this.rowId = const Value.absent(), + this.server = const Value.absent(), + this.fileId = const Value.absent(), + this.contentLength = const Value.absent(), + this.contentType = const Value.absent(), + this.etag = const Value.absent(), + this.lastModified = const Value.absent(), + this.isCollection = const Value.absent(), + this.usedBytes = const Value.absent(), + this.hasPreview = const Value.absent(), + this.ownerId = const Value.absent(), + }); + FilesCompanion.insert({ + this.rowId = const Value.absent(), + required int server, + required int fileId, + this.contentLength = const Value.absent(), + this.contentType = const Value.absent(), + this.etag = const Value.absent(), + this.lastModified = const Value.absent(), + this.isCollection = const Value.absent(), + this.usedBytes = const Value.absent(), + this.hasPreview = const Value.absent(), + this.ownerId = const Value.absent(), + }) : server = Value(server), + fileId = Value(fileId); + static Insertable custom({ + Expression? rowId, + Expression? server, + Expression? fileId, + Expression? contentLength, + Expression? contentType, + Expression? etag, + Expression? lastModified, + Expression? isCollection, + Expression? usedBytes, + Expression? hasPreview, + Expression? ownerId, + }) { + return RawValuesInsertable({ + if (rowId != null) 'row_id': rowId, + if (server != null) 'server': server, + if (fileId != null) 'file_id': fileId, + if (contentLength != null) 'content_length': contentLength, + if (contentType != null) 'content_type': contentType, + if (etag != null) 'etag': etag, + if (lastModified != null) 'last_modified': lastModified, + if (isCollection != null) 'is_collection': isCollection, + if (usedBytes != null) 'used_bytes': usedBytes, + if (hasPreview != null) 'has_preview': hasPreview, + if (ownerId != null) 'owner_id': ownerId, + }); + } + + FilesCompanion copyWith( + {Value? rowId, + Value? server, + Value? fileId, + Value? contentLength, + Value? contentType, + Value? etag, + Value? lastModified, + Value? isCollection, + Value? usedBytes, + Value? hasPreview, + Value? ownerId}) { + return FilesCompanion( + rowId: rowId ?? this.rowId, + server: server ?? this.server, + fileId: fileId ?? this.fileId, + contentLength: contentLength ?? this.contentLength, + contentType: contentType ?? this.contentType, + etag: etag ?? this.etag, + lastModified: lastModified ?? this.lastModified, + isCollection: isCollection ?? this.isCollection, + usedBytes: usedBytes ?? this.usedBytes, + hasPreview: hasPreview ?? this.hasPreview, + ownerId: ownerId ?? this.ownerId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (rowId.present) { + map['row_id'] = Variable(rowId.value); + } + if (server.present) { + map['server'] = Variable(server.value); + } + if (fileId.present) { + map['file_id'] = Variable(fileId.value); + } + if (contentLength.present) { + map['content_length'] = Variable(contentLength.value); + } + if (contentType.present) { + map['content_type'] = Variable(contentType.value); + } + if (etag.present) { + map['etag'] = Variable(etag.value); + } + if (lastModified.present) { + final converter = $FilesTable.$converter0; + map['last_modified'] = + Variable(converter.mapToSql(lastModified.value)); + } + if (isCollection.present) { + map['is_collection'] = Variable(isCollection.value); + } + if (usedBytes.present) { + map['used_bytes'] = Variable(usedBytes.value); + } + if (hasPreview.present) { + map['has_preview'] = Variable(hasPreview.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('FilesCompanion(') + ..write('rowId: $rowId, ') + ..write('server: $server, ') + ..write('fileId: $fileId, ') + ..write('contentLength: $contentLength, ') + ..write('contentType: $contentType, ') + ..write('etag: $etag, ') + ..write('lastModified: $lastModified, ') + ..write('isCollection: $isCollection, ') + ..write('usedBytes: $usedBytes, ') + ..write('hasPreview: $hasPreview, ') + ..write('ownerId: $ownerId') + ..write(')')) + .toString(); + } +} + +class $FilesTable extends Files with TableInfo<$FilesTable, File> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $FilesTable(this.attachedDatabase, [this._alias]); + final VerificationMeta _rowIdMeta = const VerificationMeta('rowId'); + @override + late final GeneratedColumn rowId = GeneratedColumn( + 'row_id', aliasedName, false, + type: const IntType(), + requiredDuringInsert: false, + defaultConstraints: 'PRIMARY KEY AUTOINCREMENT'); + final VerificationMeta _serverMeta = const VerificationMeta('server'); + @override + late final GeneratedColumn server = GeneratedColumn( + 'server', aliasedName, false, + type: const IntType(), + requiredDuringInsert: true, + defaultConstraints: 'REFERENCES servers (row_id) ON DELETE CASCADE'); + final VerificationMeta _fileIdMeta = const VerificationMeta('fileId'); + @override + late final GeneratedColumn fileId = GeneratedColumn( + 'file_id', aliasedName, false, + type: const IntType(), requiredDuringInsert: true); + final VerificationMeta _contentLengthMeta = + const VerificationMeta('contentLength'); + @override + late final GeneratedColumn contentLength = GeneratedColumn( + 'content_length', aliasedName, true, + type: const IntType(), requiredDuringInsert: false); + final VerificationMeta _contentTypeMeta = + const VerificationMeta('contentType'); + @override + late final GeneratedColumn contentType = GeneratedColumn( + 'content_type', aliasedName, true, + type: const StringType(), requiredDuringInsert: false); + final VerificationMeta _etagMeta = const VerificationMeta('etag'); + @override + late final GeneratedColumn etag = GeneratedColumn( + 'etag', aliasedName, true, + type: const StringType(), requiredDuringInsert: false); + final VerificationMeta _lastModifiedMeta = + const VerificationMeta('lastModified'); + @override + late final GeneratedColumnWithTypeConverter + lastModified = GeneratedColumn( + 'last_modified', aliasedName, true, + type: const IntType(), requiredDuringInsert: false) + .withConverter($FilesTable.$converter0); + final VerificationMeta _isCollectionMeta = + const VerificationMeta('isCollection'); + @override + late final GeneratedColumn isCollection = GeneratedColumn( + 'is_collection', aliasedName, true, + type: const BoolType(), + requiredDuringInsert: false, + defaultConstraints: 'CHECK (is_collection IN (0, 1))'); + final VerificationMeta _usedBytesMeta = const VerificationMeta('usedBytes'); + @override + late final GeneratedColumn usedBytes = GeneratedColumn( + 'used_bytes', aliasedName, true, + type: const IntType(), requiredDuringInsert: false); + final VerificationMeta _hasPreviewMeta = const VerificationMeta('hasPreview'); + @override + late final GeneratedColumn hasPreview = GeneratedColumn( + 'has_preview', aliasedName, true, + type: const BoolType(), + requiredDuringInsert: false, + defaultConstraints: 'CHECK (has_preview IN (0, 1))'); + final VerificationMeta _ownerIdMeta = const VerificationMeta('ownerId'); + @override + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, true, + type: const StringType(), requiredDuringInsert: false); + @override + List get $columns => [ + rowId, + server, + fileId, + contentLength, + contentType, + etag, + lastModified, + isCollection, + usedBytes, + hasPreview, + ownerId + ]; + @override + String get aliasedName => _alias ?? 'files'; + @override + String get actualTableName => 'files'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('row_id')) { + context.handle( + _rowIdMeta, rowId.isAcceptableOrUnknown(data['row_id']!, _rowIdMeta)); + } + if (data.containsKey('server')) { + context.handle(_serverMeta, + server.isAcceptableOrUnknown(data['server']!, _serverMeta)); + } else if (isInserting) { + context.missing(_serverMeta); + } + if (data.containsKey('file_id')) { + context.handle(_fileIdMeta, + fileId.isAcceptableOrUnknown(data['file_id']!, _fileIdMeta)); + } else if (isInserting) { + context.missing(_fileIdMeta); + } + if (data.containsKey('content_length')) { + context.handle( + _contentLengthMeta, + contentLength.isAcceptableOrUnknown( + data['content_length']!, _contentLengthMeta)); + } + if (data.containsKey('content_type')) { + context.handle( + _contentTypeMeta, + contentType.isAcceptableOrUnknown( + data['content_type']!, _contentTypeMeta)); + } + if (data.containsKey('etag')) { + context.handle( + _etagMeta, etag.isAcceptableOrUnknown(data['etag']!, _etagMeta)); + } + context.handle(_lastModifiedMeta, const VerificationResult.success()); + if (data.containsKey('is_collection')) { + context.handle( + _isCollectionMeta, + isCollection.isAcceptableOrUnknown( + data['is_collection']!, _isCollectionMeta)); + } + if (data.containsKey('used_bytes')) { + context.handle(_usedBytesMeta, + usedBytes.isAcceptableOrUnknown(data['used_bytes']!, _usedBytesMeta)); + } + if (data.containsKey('has_preview')) { + context.handle( + _hasPreviewMeta, + hasPreview.isAcceptableOrUnknown( + data['has_preview']!, _hasPreviewMeta)); + } + if (data.containsKey('owner_id')) { + context.handle(_ownerIdMeta, + ownerId.isAcceptableOrUnknown(data['owner_id']!, _ownerIdMeta)); + } + return context; + } + + @override + Set get $primaryKey => {rowId}; + @override + List> get uniqueKeys => [ + {server, fileId}, + ]; + @override + File map(Map data, {String? tablePrefix}) { + return File.fromData(data, + prefix: tablePrefix != null ? '$tablePrefix.' : null); + } + + @override + $FilesTable createAlias(String alias) { + return $FilesTable(attachedDatabase, alias); + } + + static TypeConverter $converter0 = + const _DateTimeConverter(); +} + +class AccountFile extends DataClass implements Insertable { + final int rowId; + final int account; + final int file; + final String relativePath; + final bool? isFavorite; + final bool? isArchived; + final DateTime? overrideDateTime; + AccountFile( + {required this.rowId, + required this.account, + required this.file, + required this.relativePath, + this.isFavorite, + this.isArchived, + this.overrideDateTime}); + factory AccountFile.fromData(Map data, {String? prefix}) { + final effectivePrefix = prefix ?? ''; + return AccountFile( + rowId: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}row_id'])!, + account: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}account'])!, + file: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}file'])!, + relativePath: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}relative_path'])!, + isFavorite: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}is_favorite']), + isArchived: const BoolType() + .mapFromDatabaseResponse(data['${effectivePrefix}is_archived']), + overrideDateTime: $AccountFilesTable.$converter0.mapToDart( + const DateTimeType().mapFromDatabaseResponse( + data['${effectivePrefix}override_date_time'])), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['row_id'] = Variable(rowId); + map['account'] = Variable(account); + map['file'] = Variable(file); + map['relative_path'] = Variable(relativePath); + if (!nullToAbsent || isFavorite != null) { + map['is_favorite'] = Variable(isFavorite); + } + if (!nullToAbsent || isArchived != null) { + map['is_archived'] = Variable(isArchived); + } + if (!nullToAbsent || overrideDateTime != null) { + final converter = $AccountFilesTable.$converter0; + map['override_date_time'] = + Variable(converter.mapToSql(overrideDateTime)); + } + return map; + } + + AccountFilesCompanion toCompanion(bool nullToAbsent) { + return AccountFilesCompanion( + rowId: Value(rowId), + account: Value(account), + file: Value(file), + relativePath: Value(relativePath), + isFavorite: isFavorite == null && nullToAbsent + ? const Value.absent() + : Value(isFavorite), + isArchived: isArchived == null && nullToAbsent + ? const Value.absent() + : Value(isArchived), + overrideDateTime: overrideDateTime == null && nullToAbsent + ? const Value.absent() + : Value(overrideDateTime), + ); + } + + factory AccountFile.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountFile( + rowId: serializer.fromJson(json['rowId']), + account: serializer.fromJson(json['account']), + file: serializer.fromJson(json['file']), + relativePath: serializer.fromJson(json['relativePath']), + isFavorite: serializer.fromJson(json['isFavorite']), + isArchived: serializer.fromJson(json['isArchived']), + overrideDateTime: + serializer.fromJson(json['overrideDateTime']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'rowId': serializer.toJson(rowId), + 'account': serializer.toJson(account), + 'file': serializer.toJson(file), + 'relativePath': serializer.toJson(relativePath), + 'isFavorite': serializer.toJson(isFavorite), + 'isArchived': serializer.toJson(isArchived), + 'overrideDateTime': serializer.toJson(overrideDateTime), + }; + } + + AccountFile copyWith( + {int? rowId, + int? account, + int? file, + String? relativePath, + Value isFavorite = const Value.absent(), + Value isArchived = const Value.absent(), + Value overrideDateTime = const Value.absent()}) => + AccountFile( + rowId: rowId ?? this.rowId, + account: account ?? this.account, + file: file ?? this.file, + relativePath: relativePath ?? this.relativePath, + isFavorite: isFavorite.present ? isFavorite.value : this.isFavorite, + isArchived: isArchived.present ? isArchived.value : this.isArchived, + overrideDateTime: overrideDateTime.present + ? overrideDateTime.value + : this.overrideDateTime, + ); + @override + String toString() { + return (StringBuffer('AccountFile(') + ..write('rowId: $rowId, ') + ..write('account: $account, ') + ..write('file: $file, ') + ..write('relativePath: $relativePath, ') + ..write('isFavorite: $isFavorite, ') + ..write('isArchived: $isArchived, ') + ..write('overrideDateTime: $overrideDateTime') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(rowId, account, file, relativePath, + isFavorite, isArchived, overrideDateTime); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountFile && + other.rowId == this.rowId && + other.account == this.account && + other.file == this.file && + other.relativePath == this.relativePath && + other.isFavorite == this.isFavorite && + other.isArchived == this.isArchived && + other.overrideDateTime == this.overrideDateTime); +} + +class AccountFilesCompanion extends UpdateCompanion { + final Value rowId; + final Value account; + final Value file; + final Value relativePath; + final Value isFavorite; + final Value isArchived; + final Value overrideDateTime; + const AccountFilesCompanion({ + this.rowId = const Value.absent(), + this.account = const Value.absent(), + this.file = const Value.absent(), + this.relativePath = const Value.absent(), + this.isFavorite = const Value.absent(), + this.isArchived = const Value.absent(), + this.overrideDateTime = const Value.absent(), + }); + AccountFilesCompanion.insert({ + this.rowId = const Value.absent(), + required int account, + required int file, + required String relativePath, + this.isFavorite = const Value.absent(), + this.isArchived = const Value.absent(), + this.overrideDateTime = const Value.absent(), + }) : account = Value(account), + file = Value(file), + relativePath = Value(relativePath); + static Insertable custom({ + Expression? rowId, + Expression? account, + Expression? file, + Expression? relativePath, + Expression? isFavorite, + Expression? isArchived, + Expression? overrideDateTime, + }) { + return RawValuesInsertable({ + if (rowId != null) 'row_id': rowId, + if (account != null) 'account': account, + if (file != null) 'file': file, + if (relativePath != null) 'relative_path': relativePath, + if (isFavorite != null) 'is_favorite': isFavorite, + if (isArchived != null) 'is_archived': isArchived, + if (overrideDateTime != null) 'override_date_time': overrideDateTime, + }); + } + + AccountFilesCompanion copyWith( + {Value? rowId, + Value? account, + Value? file, + Value? relativePath, + Value? isFavorite, + Value? isArchived, + Value? overrideDateTime}) { + return AccountFilesCompanion( + rowId: rowId ?? this.rowId, + account: account ?? this.account, + file: file ?? this.file, + relativePath: relativePath ?? this.relativePath, + isFavorite: isFavorite ?? this.isFavorite, + isArchived: isArchived ?? this.isArchived, + overrideDateTime: overrideDateTime ?? this.overrideDateTime, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (rowId.present) { + map['row_id'] = Variable(rowId.value); + } + if (account.present) { + map['account'] = Variable(account.value); + } + if (file.present) { + map['file'] = Variable(file.value); + } + if (relativePath.present) { + map['relative_path'] = Variable(relativePath.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (isArchived.present) { + map['is_archived'] = Variable(isArchived.value); + } + if (overrideDateTime.present) { + final converter = $AccountFilesTable.$converter0; + map['override_date_time'] = + Variable(converter.mapToSql(overrideDateTime.value)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountFilesCompanion(') + ..write('rowId: $rowId, ') + ..write('account: $account, ') + ..write('file: $file, ') + ..write('relativePath: $relativePath, ') + ..write('isFavorite: $isFavorite, ') + ..write('isArchived: $isArchived, ') + ..write('overrideDateTime: $overrideDateTime') + ..write(')')) + .toString(); + } +} + +class $AccountFilesTable extends AccountFiles + with TableInfo<$AccountFilesTable, AccountFile> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AccountFilesTable(this.attachedDatabase, [this._alias]); + final VerificationMeta _rowIdMeta = const VerificationMeta('rowId'); + @override + late final GeneratedColumn rowId = GeneratedColumn( + 'row_id', aliasedName, false, + type: const IntType(), + requiredDuringInsert: false, + defaultConstraints: 'PRIMARY KEY AUTOINCREMENT'); + final VerificationMeta _accountMeta = const VerificationMeta('account'); + @override + late final GeneratedColumn account = GeneratedColumn( + 'account', aliasedName, false, + type: const IntType(), + requiredDuringInsert: true, + defaultConstraints: 'REFERENCES accounts (row_id) ON DELETE CASCADE'); + final VerificationMeta _fileMeta = const VerificationMeta('file'); + @override + late final GeneratedColumn file = GeneratedColumn( + 'file', aliasedName, false, + type: const IntType(), + requiredDuringInsert: true, + defaultConstraints: 'REFERENCES files (row_id) ON DELETE CASCADE'); + final VerificationMeta _relativePathMeta = + const VerificationMeta('relativePath'); + @override + late final GeneratedColumn relativePath = GeneratedColumn( + 'relative_path', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + final VerificationMeta _isFavoriteMeta = const VerificationMeta('isFavorite'); + @override + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', aliasedName, true, + type: const BoolType(), + requiredDuringInsert: false, + defaultConstraints: 'CHECK (is_favorite IN (0, 1))'); + final VerificationMeta _isArchivedMeta = const VerificationMeta('isArchived'); + @override + late final GeneratedColumn isArchived = GeneratedColumn( + 'is_archived', aliasedName, true, + type: const BoolType(), + requiredDuringInsert: false, + defaultConstraints: 'CHECK (is_archived IN (0, 1))'); + final VerificationMeta _overrideDateTimeMeta = + const VerificationMeta('overrideDateTime'); + @override + late final GeneratedColumnWithTypeConverter + overrideDateTime = GeneratedColumn( + 'override_date_time', aliasedName, true, + type: const IntType(), requiredDuringInsert: false) + .withConverter($AccountFilesTable.$converter0); + @override + List get $columns => [ + rowId, + account, + file, + relativePath, + isFavorite, + isArchived, + overrideDateTime + ]; + @override + String get aliasedName => _alias ?? 'account_files'; + @override + String get actualTableName => 'account_files'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('row_id')) { + context.handle( + _rowIdMeta, rowId.isAcceptableOrUnknown(data['row_id']!, _rowIdMeta)); + } + if (data.containsKey('account')) { + context.handle(_accountMeta, + account.isAcceptableOrUnknown(data['account']!, _accountMeta)); + } else if (isInserting) { + context.missing(_accountMeta); + } + if (data.containsKey('file')) { + context.handle( + _fileMeta, file.isAcceptableOrUnknown(data['file']!, _fileMeta)); + } else if (isInserting) { + context.missing(_fileMeta); + } + if (data.containsKey('relative_path')) { + context.handle( + _relativePathMeta, + relativePath.isAcceptableOrUnknown( + data['relative_path']!, _relativePathMeta)); + } else if (isInserting) { + context.missing(_relativePathMeta); + } + if (data.containsKey('is_favorite')) { + context.handle( + _isFavoriteMeta, + isFavorite.isAcceptableOrUnknown( + data['is_favorite']!, _isFavoriteMeta)); + } + if (data.containsKey('is_archived')) { + context.handle( + _isArchivedMeta, + isArchived.isAcceptableOrUnknown( + data['is_archived']!, _isArchivedMeta)); + } + context.handle(_overrideDateTimeMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {rowId}; + @override + List> get uniqueKeys => [ + {account, file}, + ]; + @override + AccountFile map(Map data, {String? tablePrefix}) { + return AccountFile.fromData(data, + prefix: tablePrefix != null ? '$tablePrefix.' : null); + } + + @override + $AccountFilesTable createAlias(String alias) { + return $AccountFilesTable(attachedDatabase, alias); + } + + static TypeConverter $converter0 = + const _DateTimeConverter(); +} + +class Image extends DataClass implements Insertable { + final int accountFile; + final DateTime lastUpdated; + final String? fileEtag; + final int? width; + final int? height; + final String? exifRaw; + final DateTime? dateTimeOriginal; + Image( + {required this.accountFile, + required this.lastUpdated, + this.fileEtag, + this.width, + this.height, + this.exifRaw, + this.dateTimeOriginal}); + factory Image.fromData(Map data, {String? prefix}) { + final effectivePrefix = prefix ?? ''; + return Image( + accountFile: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}account_file'])!, + lastUpdated: $ImagesTable.$converter0.mapToDart(const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}last_updated']))!, + fileEtag: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}file_etag']), + width: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}width']), + height: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}height']), + exifRaw: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}exif_raw']), + dateTimeOriginal: $ImagesTable.$converter1.mapToDart(const DateTimeType() + .mapFromDatabaseResponse( + data['${effectivePrefix}date_time_original'])), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['account_file'] = Variable(accountFile); + { + final converter = $ImagesTable.$converter0; + map['last_updated'] = + Variable(converter.mapToSql(lastUpdated)!); + } + if (!nullToAbsent || fileEtag != null) { + map['file_etag'] = Variable(fileEtag); + } + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || exifRaw != null) { + map['exif_raw'] = Variable(exifRaw); + } + if (!nullToAbsent || dateTimeOriginal != null) { + final converter = $ImagesTable.$converter1; + map['date_time_original'] = + Variable(converter.mapToSql(dateTimeOriginal)); + } + return map; + } + + ImagesCompanion toCompanion(bool nullToAbsent) { + return ImagesCompanion( + accountFile: Value(accountFile), + lastUpdated: Value(lastUpdated), + fileEtag: fileEtag == null && nullToAbsent + ? const Value.absent() + : Value(fileEtag), + width: + width == null && nullToAbsent ? const Value.absent() : Value(width), + height: + height == null && nullToAbsent ? const Value.absent() : Value(height), + exifRaw: exifRaw == null && nullToAbsent + ? const Value.absent() + : Value(exifRaw), + dateTimeOriginal: dateTimeOriginal == null && nullToAbsent + ? const Value.absent() + : Value(dateTimeOriginal), + ); + } + + factory Image.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Image( + accountFile: serializer.fromJson(json['accountFile']), + lastUpdated: serializer.fromJson(json['lastUpdated']), + fileEtag: serializer.fromJson(json['fileEtag']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + exifRaw: serializer.fromJson(json['exifRaw']), + dateTimeOriginal: + serializer.fromJson(json['dateTimeOriginal']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'accountFile': serializer.toJson(accountFile), + 'lastUpdated': serializer.toJson(lastUpdated), + 'fileEtag': serializer.toJson(fileEtag), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'exifRaw': serializer.toJson(exifRaw), + 'dateTimeOriginal': serializer.toJson(dateTimeOriginal), + }; + } + + Image copyWith( + {int? accountFile, + DateTime? lastUpdated, + Value fileEtag = const Value.absent(), + Value width = const Value.absent(), + Value height = const Value.absent(), + Value exifRaw = const Value.absent(), + Value dateTimeOriginal = const Value.absent()}) => + Image( + accountFile: accountFile ?? this.accountFile, + lastUpdated: lastUpdated ?? this.lastUpdated, + fileEtag: fileEtag.present ? fileEtag.value : this.fileEtag, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + exifRaw: exifRaw.present ? exifRaw.value : this.exifRaw, + dateTimeOriginal: dateTimeOriginal.present + ? dateTimeOriginal.value + : this.dateTimeOriginal, + ); + @override + String toString() { + return (StringBuffer('Image(') + ..write('accountFile: $accountFile, ') + ..write('lastUpdated: $lastUpdated, ') + ..write('fileEtag: $fileEtag, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('exifRaw: $exifRaw, ') + ..write('dateTimeOriginal: $dateTimeOriginal') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(accountFile, lastUpdated, fileEtag, width, + height, exifRaw, dateTimeOriginal); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Image && + other.accountFile == this.accountFile && + other.lastUpdated == this.lastUpdated && + other.fileEtag == this.fileEtag && + other.width == this.width && + other.height == this.height && + other.exifRaw == this.exifRaw && + other.dateTimeOriginal == this.dateTimeOriginal); +} + +class ImagesCompanion extends UpdateCompanion { + final Value accountFile; + final Value lastUpdated; + final Value fileEtag; + final Value width; + final Value height; + final Value exifRaw; + final Value dateTimeOriginal; + const ImagesCompanion({ + this.accountFile = const Value.absent(), + this.lastUpdated = const Value.absent(), + this.fileEtag = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.exifRaw = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + }); + ImagesCompanion.insert({ + this.accountFile = const Value.absent(), + required DateTime lastUpdated, + this.fileEtag = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.exifRaw = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + }) : lastUpdated = Value(lastUpdated); + static Insertable custom({ + Expression? accountFile, + Expression? lastUpdated, + Expression? fileEtag, + Expression? width, + Expression? height, + Expression? exifRaw, + Expression? dateTimeOriginal, + }) { + return RawValuesInsertable({ + if (accountFile != null) 'account_file': accountFile, + if (lastUpdated != null) 'last_updated': lastUpdated, + if (fileEtag != null) 'file_etag': fileEtag, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (exifRaw != null) 'exif_raw': exifRaw, + if (dateTimeOriginal != null) 'date_time_original': dateTimeOriginal, + }); + } + + ImagesCompanion copyWith( + {Value? accountFile, + Value? lastUpdated, + Value? fileEtag, + Value? width, + Value? height, + Value? exifRaw, + Value? dateTimeOriginal}) { + return ImagesCompanion( + accountFile: accountFile ?? this.accountFile, + lastUpdated: lastUpdated ?? this.lastUpdated, + fileEtag: fileEtag ?? this.fileEtag, + width: width ?? this.width, + height: height ?? this.height, + exifRaw: exifRaw ?? this.exifRaw, + dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (accountFile.present) { + map['account_file'] = Variable(accountFile.value); + } + if (lastUpdated.present) { + final converter = $ImagesTable.$converter0; + map['last_updated'] = + Variable(converter.mapToSql(lastUpdated.value)!); + } + if (fileEtag.present) { + map['file_etag'] = Variable(fileEtag.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (exifRaw.present) { + map['exif_raw'] = Variable(exifRaw.value); + } + if (dateTimeOriginal.present) { + final converter = $ImagesTable.$converter1; + map['date_time_original'] = + Variable(converter.mapToSql(dateTimeOriginal.value)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ImagesCompanion(') + ..write('accountFile: $accountFile, ') + ..write('lastUpdated: $lastUpdated, ') + ..write('fileEtag: $fileEtag, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('exifRaw: $exifRaw, ') + ..write('dateTimeOriginal: $dateTimeOriginal') + ..write(')')) + .toString(); + } +} + +class $ImagesTable extends Images with TableInfo<$ImagesTable, Image> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ImagesTable(this.attachedDatabase, [this._alias]); + final VerificationMeta _accountFileMeta = + const VerificationMeta('accountFile'); + @override + late final GeneratedColumn accountFile = GeneratedColumn( + 'account_file', aliasedName, false, + type: const IntType(), + requiredDuringInsert: false, + defaultConstraints: + 'REFERENCES account_files (row_id) ON DELETE CASCADE'); + final VerificationMeta _lastUpdatedMeta = + const VerificationMeta('lastUpdated'); + @override + late final GeneratedColumnWithTypeConverter lastUpdated = + GeneratedColumn('last_updated', aliasedName, false, + type: const IntType(), requiredDuringInsert: true) + .withConverter($ImagesTable.$converter0); + final VerificationMeta _fileEtagMeta = const VerificationMeta('fileEtag'); + @override + late final GeneratedColumn fileEtag = GeneratedColumn( + 'file_etag', aliasedName, true, + type: const StringType(), requiredDuringInsert: false); + final VerificationMeta _widthMeta = const VerificationMeta('width'); + @override + late final GeneratedColumn width = GeneratedColumn( + 'width', aliasedName, true, + type: const IntType(), requiredDuringInsert: false); + final VerificationMeta _heightMeta = const VerificationMeta('height'); + @override + late final GeneratedColumn height = GeneratedColumn( + 'height', aliasedName, true, + type: const IntType(), requiredDuringInsert: false); + final VerificationMeta _exifRawMeta = const VerificationMeta('exifRaw'); + @override + late final GeneratedColumn exifRaw = GeneratedColumn( + 'exif_raw', aliasedName, true, + type: const StringType(), requiredDuringInsert: false); + final VerificationMeta _dateTimeOriginalMeta = + const VerificationMeta('dateTimeOriginal'); + @override + late final GeneratedColumnWithTypeConverter + dateTimeOriginal = GeneratedColumn( + 'date_time_original', aliasedName, true, + type: const IntType(), requiredDuringInsert: false) + .withConverter($ImagesTable.$converter1); + @override + List get $columns => [ + accountFile, + lastUpdated, + fileEtag, + width, + height, + exifRaw, + dateTimeOriginal + ]; + @override + String get aliasedName => _alias ?? 'images'; + @override + String get actualTableName => 'images'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('account_file')) { + context.handle( + _accountFileMeta, + accountFile.isAcceptableOrUnknown( + data['account_file']!, _accountFileMeta)); + } + context.handle(_lastUpdatedMeta, const VerificationResult.success()); + if (data.containsKey('file_etag')) { + context.handle(_fileEtagMeta, + fileEtag.isAcceptableOrUnknown(data['file_etag']!, _fileEtagMeta)); + } + if (data.containsKey('width')) { + context.handle( + _widthMeta, width.isAcceptableOrUnknown(data['width']!, _widthMeta)); + } + if (data.containsKey('height')) { + context.handle(_heightMeta, + height.isAcceptableOrUnknown(data['height']!, _heightMeta)); + } + if (data.containsKey('exif_raw')) { + context.handle(_exifRawMeta, + exifRaw.isAcceptableOrUnknown(data['exif_raw']!, _exifRawMeta)); + } + context.handle(_dateTimeOriginalMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {accountFile}; + @override + Image map(Map data, {String? tablePrefix}) { + return Image.fromData(data, + prefix: tablePrefix != null ? '$tablePrefix.' : null); + } + + @override + $ImagesTable createAlias(String alias) { + return $ImagesTable(attachedDatabase, alias); + } + + static TypeConverter $converter0 = + const _DateTimeConverter(); + static TypeConverter $converter1 = + const _DateTimeConverter(); +} + +class Trash extends DataClass implements Insertable { + final int file; + final String filename; + final String originalLocation; + final DateTime deletionTime; + Trash( + {required this.file, + required this.filename, + required this.originalLocation, + required this.deletionTime}); + factory Trash.fromData(Map data, {String? prefix}) { + final effectivePrefix = prefix ?? ''; + return Trash( + file: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}file'])!, + filename: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}filename'])!, + originalLocation: const StringType().mapFromDatabaseResponse( + data['${effectivePrefix}original_location'])!, + deletionTime: $TrashesTable.$converter0.mapToDart(const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}deletion_time']))!, + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['file'] = Variable(file); + map['filename'] = Variable(filename); + map['original_location'] = Variable(originalLocation); + { + final converter = $TrashesTable.$converter0; + map['deletion_time'] = + Variable(converter.mapToSql(deletionTime)!); + } + return map; + } + + TrashesCompanion toCompanion(bool nullToAbsent) { + return TrashesCompanion( + file: Value(file), + filename: Value(filename), + originalLocation: Value(originalLocation), + deletionTime: Value(deletionTime), + ); + } + + factory Trash.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Trash( + file: serializer.fromJson(json['file']), + filename: serializer.fromJson(json['filename']), + originalLocation: serializer.fromJson(json['originalLocation']), + deletionTime: serializer.fromJson(json['deletionTime']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'file': serializer.toJson(file), + 'filename': serializer.toJson(filename), + 'originalLocation': serializer.toJson(originalLocation), + 'deletionTime': serializer.toJson(deletionTime), + }; + } + + Trash copyWith( + {int? file, + String? filename, + String? originalLocation, + DateTime? deletionTime}) => + Trash( + file: file ?? this.file, + filename: filename ?? this.filename, + originalLocation: originalLocation ?? this.originalLocation, + deletionTime: deletionTime ?? this.deletionTime, + ); + @override + String toString() { + return (StringBuffer('Trash(') + ..write('file: $file, ') + ..write('filename: $filename, ') + ..write('originalLocation: $originalLocation, ') + ..write('deletionTime: $deletionTime') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(file, filename, originalLocation, deletionTime); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Trash && + other.file == this.file && + other.filename == this.filename && + other.originalLocation == this.originalLocation && + other.deletionTime == this.deletionTime); +} + +class TrashesCompanion extends UpdateCompanion { + final Value file; + final Value filename; + final Value originalLocation; + final Value deletionTime; + const TrashesCompanion({ + this.file = const Value.absent(), + this.filename = const Value.absent(), + this.originalLocation = const Value.absent(), + this.deletionTime = const Value.absent(), + }); + TrashesCompanion.insert({ + this.file = const Value.absent(), + required String filename, + required String originalLocation, + required DateTime deletionTime, + }) : filename = Value(filename), + originalLocation = Value(originalLocation), + deletionTime = Value(deletionTime); + static Insertable custom({ + Expression? file, + Expression? filename, + Expression? originalLocation, + Expression? deletionTime, + }) { + return RawValuesInsertable({ + if (file != null) 'file': file, + if (filename != null) 'filename': filename, + if (originalLocation != null) 'original_location': originalLocation, + if (deletionTime != null) 'deletion_time': deletionTime, + }); + } + + TrashesCompanion copyWith( + {Value? file, + Value? filename, + Value? originalLocation, + Value? deletionTime}) { + return TrashesCompanion( + file: file ?? this.file, + filename: filename ?? this.filename, + originalLocation: originalLocation ?? this.originalLocation, + deletionTime: deletionTime ?? this.deletionTime, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (file.present) { + map['file'] = Variable(file.value); + } + if (filename.present) { + map['filename'] = Variable(filename.value); + } + if (originalLocation.present) { + map['original_location'] = Variable(originalLocation.value); + } + if (deletionTime.present) { + final converter = $TrashesTable.$converter0; + map['deletion_time'] = + Variable(converter.mapToSql(deletionTime.value)!); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TrashesCompanion(') + ..write('file: $file, ') + ..write('filename: $filename, ') + ..write('originalLocation: $originalLocation, ') + ..write('deletionTime: $deletionTime') + ..write(')')) + .toString(); + } +} + +class $TrashesTable extends Trashes with TableInfo<$TrashesTable, Trash> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $TrashesTable(this.attachedDatabase, [this._alias]); + final VerificationMeta _fileMeta = const VerificationMeta('file'); + @override + late final GeneratedColumn file = GeneratedColumn( + 'file', aliasedName, false, + type: const IntType(), + requiredDuringInsert: false, + defaultConstraints: 'REFERENCES files (row_id) ON DELETE CASCADE'); + final VerificationMeta _filenameMeta = const VerificationMeta('filename'); + @override + late final GeneratedColumn filename = GeneratedColumn( + 'filename', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + final VerificationMeta _originalLocationMeta = + const VerificationMeta('originalLocation'); + @override + late final GeneratedColumn originalLocation = + GeneratedColumn('original_location', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + final VerificationMeta _deletionTimeMeta = + const VerificationMeta('deletionTime'); + @override + late final GeneratedColumnWithTypeConverter + deletionTime = GeneratedColumn( + 'deletion_time', aliasedName, false, + type: const IntType(), requiredDuringInsert: true) + .withConverter($TrashesTable.$converter0); + @override + List get $columns => + [file, filename, originalLocation, deletionTime]; + @override + String get aliasedName => _alias ?? 'trashes'; + @override + String get actualTableName => 'trashes'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('file')) { + context.handle( + _fileMeta, file.isAcceptableOrUnknown(data['file']!, _fileMeta)); + } + if (data.containsKey('filename')) { + context.handle(_filenameMeta, + filename.isAcceptableOrUnknown(data['filename']!, _filenameMeta)); + } else if (isInserting) { + context.missing(_filenameMeta); + } + if (data.containsKey('original_location')) { + context.handle( + _originalLocationMeta, + originalLocation.isAcceptableOrUnknown( + data['original_location']!, _originalLocationMeta)); + } else if (isInserting) { + context.missing(_originalLocationMeta); + } + context.handle(_deletionTimeMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {file}; + @override + Trash map(Map data, {String? tablePrefix}) { + return Trash.fromData(data, + prefix: tablePrefix != null ? '$tablePrefix.' : null); + } + + @override + $TrashesTable createAlias(String alias) { + return $TrashesTable(attachedDatabase, alias); + } + + static TypeConverter $converter0 = + const _DateTimeConverter(); +} + +class DirFile extends DataClass implements Insertable { + final int dir; + final int child; + DirFile({required this.dir, required this.child}); + factory DirFile.fromData(Map data, {String? prefix}) { + final effectivePrefix = prefix ?? ''; + return DirFile( + dir: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}dir'])!, + child: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}child'])!, + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['dir'] = Variable(dir); + map['child'] = Variable(child); + return map; + } + + DirFilesCompanion toCompanion(bool nullToAbsent) { + return DirFilesCompanion( + dir: Value(dir), + child: Value(child), + ); + } + + factory DirFile.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return DirFile( + dir: serializer.fromJson(json['dir']), + child: serializer.fromJson(json['child']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'dir': serializer.toJson(dir), + 'child': serializer.toJson(child), + }; + } + + DirFile copyWith({int? dir, int? child}) => DirFile( + dir: dir ?? this.dir, + child: child ?? this.child, + ); + @override + String toString() { + return (StringBuffer('DirFile(') + ..write('dir: $dir, ') + ..write('child: $child') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(dir, child); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is DirFile && other.dir == this.dir && other.child == this.child); +} + +class DirFilesCompanion extends UpdateCompanion { + final Value dir; + final Value child; + const DirFilesCompanion({ + this.dir = const Value.absent(), + this.child = const Value.absent(), + }); + DirFilesCompanion.insert({ + required int dir, + required int child, + }) : dir = Value(dir), + child = Value(child); + static Insertable custom({ + Expression? dir, + Expression? child, + }) { + return RawValuesInsertable({ + if (dir != null) 'dir': dir, + if (child != null) 'child': child, + }); + } + + DirFilesCompanion copyWith({Value? dir, Value? child}) { + return DirFilesCompanion( + dir: dir ?? this.dir, + child: child ?? this.child, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (dir.present) { + map['dir'] = Variable(dir.value); + } + if (child.present) { + map['child'] = Variable(child.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('DirFilesCompanion(') + ..write('dir: $dir, ') + ..write('child: $child') + ..write(')')) + .toString(); + } +} + +class $DirFilesTable extends DirFiles with TableInfo<$DirFilesTable, DirFile> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $DirFilesTable(this.attachedDatabase, [this._alias]); + final VerificationMeta _dirMeta = const VerificationMeta('dir'); + @override + late final GeneratedColumn dir = GeneratedColumn( + 'dir', aliasedName, false, + type: const IntType(), + requiredDuringInsert: true, + defaultConstraints: 'REFERENCES files (row_id) ON DELETE CASCADE'); + final VerificationMeta _childMeta = const VerificationMeta('child'); + @override + late final GeneratedColumn child = GeneratedColumn( + 'child', aliasedName, false, + type: const IntType(), + requiredDuringInsert: true, + defaultConstraints: 'REFERENCES files (row_id) ON DELETE CASCADE'); + @override + List get $columns => [dir, child]; + @override + String get aliasedName => _alias ?? 'dir_files'; + @override + String get actualTableName => 'dir_files'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('dir')) { + context.handle( + _dirMeta, dir.isAcceptableOrUnknown(data['dir']!, _dirMeta)); + } else if (isInserting) { + context.missing(_dirMeta); + } + if (data.containsKey('child')) { + context.handle( + _childMeta, child.isAcceptableOrUnknown(data['child']!, _childMeta)); + } else if (isInserting) { + context.missing(_childMeta); + } + return context; + } + + @override + Set get $primaryKey => {dir, child}; + @override + DirFile map(Map data, {String? tablePrefix}) { + return DirFile.fromData(data, + prefix: tablePrefix != null ? '$tablePrefix.' : null); + } + + @override + $DirFilesTable createAlias(String alias) { + return $DirFilesTable(attachedDatabase, alias); + } +} + +class Album extends DataClass implements Insertable { + final int rowId; + final int file; + final int version; + final DateTime lastUpdated; + final String name; + final String providerType; + final String providerContent; + final String coverProviderType; + final String coverProviderContent; + final String sortProviderType; + final String sortProviderContent; + Album( + {required this.rowId, + required this.file, + required this.version, + required this.lastUpdated, + required this.name, + required this.providerType, + required this.providerContent, + required this.coverProviderType, + required this.coverProviderContent, + required this.sortProviderType, + required this.sortProviderContent}); + factory Album.fromData(Map data, {String? prefix}) { + final effectivePrefix = prefix ?? ''; + return Album( + rowId: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}row_id'])!, + file: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}file'])!, + version: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}version'])!, + lastUpdated: $AlbumsTable.$converter0.mapToDart(const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}last_updated']))!, + name: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}name'])!, + providerType: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}provider_type'])!, + providerContent: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}provider_content'])!, + coverProviderType: const StringType().mapFromDatabaseResponse( + data['${effectivePrefix}cover_provider_type'])!, + coverProviderContent: const StringType().mapFromDatabaseResponse( + data['${effectivePrefix}cover_provider_content'])!, + sortProviderType: const StringType().mapFromDatabaseResponse( + data['${effectivePrefix}sort_provider_type'])!, + sortProviderContent: const StringType().mapFromDatabaseResponse( + data['${effectivePrefix}sort_provider_content'])!, + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['row_id'] = Variable(rowId); + map['file'] = Variable(file); + map['version'] = Variable(version); + { + final converter = $AlbumsTable.$converter0; + map['last_updated'] = + Variable(converter.mapToSql(lastUpdated)!); + } + map['name'] = Variable(name); + map['provider_type'] = Variable(providerType); + map['provider_content'] = Variable(providerContent); + map['cover_provider_type'] = Variable(coverProviderType); + map['cover_provider_content'] = Variable(coverProviderContent); + map['sort_provider_type'] = Variable(sortProviderType); + map['sort_provider_content'] = Variable(sortProviderContent); + return map; + } + + AlbumsCompanion toCompanion(bool nullToAbsent) { + return AlbumsCompanion( + rowId: Value(rowId), + file: Value(file), + version: Value(version), + lastUpdated: Value(lastUpdated), + name: Value(name), + providerType: Value(providerType), + providerContent: Value(providerContent), + coverProviderType: Value(coverProviderType), + coverProviderContent: Value(coverProviderContent), + sortProviderType: Value(sortProviderType), + sortProviderContent: Value(sortProviderContent), + ); + } + + factory Album.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Album( + rowId: serializer.fromJson(json['rowId']), + file: serializer.fromJson(json['file']), + version: serializer.fromJson(json['version']), + lastUpdated: serializer.fromJson(json['lastUpdated']), + name: serializer.fromJson(json['name']), + providerType: serializer.fromJson(json['providerType']), + providerContent: serializer.fromJson(json['providerContent']), + coverProviderType: serializer.fromJson(json['coverProviderType']), + coverProviderContent: + serializer.fromJson(json['coverProviderContent']), + sortProviderType: serializer.fromJson(json['sortProviderType']), + sortProviderContent: + serializer.fromJson(json['sortProviderContent']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'rowId': serializer.toJson(rowId), + 'file': serializer.toJson(file), + 'version': serializer.toJson(version), + 'lastUpdated': serializer.toJson(lastUpdated), + 'name': serializer.toJson(name), + 'providerType': serializer.toJson(providerType), + 'providerContent': serializer.toJson(providerContent), + 'coverProviderType': serializer.toJson(coverProviderType), + 'coverProviderContent': serializer.toJson(coverProviderContent), + 'sortProviderType': serializer.toJson(sortProviderType), + 'sortProviderContent': serializer.toJson(sortProviderContent), + }; + } + + Album copyWith( + {int? rowId, + int? file, + int? version, + DateTime? lastUpdated, + String? name, + String? providerType, + String? providerContent, + String? coverProviderType, + String? coverProviderContent, + String? sortProviderType, + String? sortProviderContent}) => + Album( + rowId: rowId ?? this.rowId, + file: file ?? this.file, + version: version ?? this.version, + lastUpdated: lastUpdated ?? this.lastUpdated, + name: name ?? this.name, + providerType: providerType ?? this.providerType, + providerContent: providerContent ?? this.providerContent, + coverProviderType: coverProviderType ?? this.coverProviderType, + coverProviderContent: coverProviderContent ?? this.coverProviderContent, + sortProviderType: sortProviderType ?? this.sortProviderType, + sortProviderContent: sortProviderContent ?? this.sortProviderContent, + ); + @override + String toString() { + return (StringBuffer('Album(') + ..write('rowId: $rowId, ') + ..write('file: $file, ') + ..write('version: $version, ') + ..write('lastUpdated: $lastUpdated, ') + ..write('name: $name, ') + ..write('providerType: $providerType, ') + ..write('providerContent: $providerContent, ') + ..write('coverProviderType: $coverProviderType, ') + ..write('coverProviderContent: $coverProviderContent, ') + ..write('sortProviderType: $sortProviderType, ') + ..write('sortProviderContent: $sortProviderContent') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + rowId, + file, + version, + lastUpdated, + name, + providerType, + providerContent, + coverProviderType, + coverProviderContent, + sortProviderType, + sortProviderContent); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Album && + other.rowId == this.rowId && + other.file == this.file && + other.version == this.version && + other.lastUpdated == this.lastUpdated && + other.name == this.name && + other.providerType == this.providerType && + other.providerContent == this.providerContent && + other.coverProviderType == this.coverProviderType && + other.coverProviderContent == this.coverProviderContent && + other.sortProviderType == this.sortProviderType && + other.sortProviderContent == this.sortProviderContent); +} + +class AlbumsCompanion extends UpdateCompanion { + final Value rowId; + final Value file; + final Value version; + final Value lastUpdated; + final Value name; + final Value providerType; + final Value providerContent; + final Value coverProviderType; + final Value coverProviderContent; + final Value sortProviderType; + final Value sortProviderContent; + const AlbumsCompanion({ + this.rowId = const Value.absent(), + this.file = const Value.absent(), + this.version = const Value.absent(), + this.lastUpdated = const Value.absent(), + this.name = const Value.absent(), + this.providerType = const Value.absent(), + this.providerContent = const Value.absent(), + this.coverProviderType = const Value.absent(), + this.coverProviderContent = const Value.absent(), + this.sortProviderType = const Value.absent(), + this.sortProviderContent = const Value.absent(), + }); + AlbumsCompanion.insert({ + this.rowId = const Value.absent(), + required int file, + required int version, + required DateTime lastUpdated, + required String name, + required String providerType, + required String providerContent, + required String coverProviderType, + required String coverProviderContent, + required String sortProviderType, + required String sortProviderContent, + }) : file = Value(file), + version = Value(version), + lastUpdated = Value(lastUpdated), + name = Value(name), + providerType = Value(providerType), + providerContent = Value(providerContent), + coverProviderType = Value(coverProviderType), + coverProviderContent = Value(coverProviderContent), + sortProviderType = Value(sortProviderType), + sortProviderContent = Value(sortProviderContent); + static Insertable custom({ + Expression? rowId, + Expression? file, + Expression? version, + Expression? lastUpdated, + Expression? name, + Expression? providerType, + Expression? providerContent, + Expression? coverProviderType, + Expression? coverProviderContent, + Expression? sortProviderType, + Expression? sortProviderContent, + }) { + return RawValuesInsertable({ + if (rowId != null) 'row_id': rowId, + if (file != null) 'file': file, + if (version != null) 'version': version, + if (lastUpdated != null) 'last_updated': lastUpdated, + if (name != null) 'name': name, + if (providerType != null) 'provider_type': providerType, + if (providerContent != null) 'provider_content': providerContent, + if (coverProviderType != null) 'cover_provider_type': coverProviderType, + if (coverProviderContent != null) + 'cover_provider_content': coverProviderContent, + if (sortProviderType != null) 'sort_provider_type': sortProviderType, + if (sortProviderContent != null) + 'sort_provider_content': sortProviderContent, + }); + } + + AlbumsCompanion copyWith( + {Value? rowId, + Value? file, + Value? version, + Value? lastUpdated, + Value? name, + Value? providerType, + Value? providerContent, + Value? coverProviderType, + Value? coverProviderContent, + Value? sortProviderType, + Value? sortProviderContent}) { + return AlbumsCompanion( + rowId: rowId ?? this.rowId, + file: file ?? this.file, + version: version ?? this.version, + lastUpdated: lastUpdated ?? this.lastUpdated, + name: name ?? this.name, + providerType: providerType ?? this.providerType, + providerContent: providerContent ?? this.providerContent, + coverProviderType: coverProviderType ?? this.coverProviderType, + coverProviderContent: coverProviderContent ?? this.coverProviderContent, + sortProviderType: sortProviderType ?? this.sortProviderType, + sortProviderContent: sortProviderContent ?? this.sortProviderContent, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (rowId.present) { + map['row_id'] = Variable(rowId.value); + } + if (file.present) { + map['file'] = Variable(file.value); + } + if (version.present) { + map['version'] = Variable(version.value); + } + if (lastUpdated.present) { + final converter = $AlbumsTable.$converter0; + map['last_updated'] = + Variable(converter.mapToSql(lastUpdated.value)!); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (providerType.present) { + map['provider_type'] = Variable(providerType.value); + } + if (providerContent.present) { + map['provider_content'] = Variable(providerContent.value); + } + if (coverProviderType.present) { + map['cover_provider_type'] = Variable(coverProviderType.value); + } + if (coverProviderContent.present) { + map['cover_provider_content'] = + Variable(coverProviderContent.value); + } + if (sortProviderType.present) { + map['sort_provider_type'] = Variable(sortProviderType.value); + } + if (sortProviderContent.present) { + map['sort_provider_content'] = + Variable(sortProviderContent.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AlbumsCompanion(') + ..write('rowId: $rowId, ') + ..write('file: $file, ') + ..write('version: $version, ') + ..write('lastUpdated: $lastUpdated, ') + ..write('name: $name, ') + ..write('providerType: $providerType, ') + ..write('providerContent: $providerContent, ') + ..write('coverProviderType: $coverProviderType, ') + ..write('coverProviderContent: $coverProviderContent, ') + ..write('sortProviderType: $sortProviderType, ') + ..write('sortProviderContent: $sortProviderContent') + ..write(')')) + .toString(); + } +} + +class $AlbumsTable extends Albums with TableInfo<$AlbumsTable, Album> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AlbumsTable(this.attachedDatabase, [this._alias]); + final VerificationMeta _rowIdMeta = const VerificationMeta('rowId'); + @override + late final GeneratedColumn rowId = GeneratedColumn( + 'row_id', aliasedName, false, + type: const IntType(), + requiredDuringInsert: false, + defaultConstraints: 'PRIMARY KEY AUTOINCREMENT'); + final VerificationMeta _fileMeta = const VerificationMeta('file'); + @override + late final GeneratedColumn file = GeneratedColumn( + 'file', aliasedName, false, + type: const IntType(), + requiredDuringInsert: true, + defaultConstraints: 'UNIQUE REFERENCES files (row_id) ON DELETE CASCADE'); + final VerificationMeta _versionMeta = const VerificationMeta('version'); + @override + late final GeneratedColumn version = GeneratedColumn( + 'version', aliasedName, false, + type: const IntType(), requiredDuringInsert: true); + final VerificationMeta _lastUpdatedMeta = + const VerificationMeta('lastUpdated'); + @override + late final GeneratedColumnWithTypeConverter lastUpdated = + GeneratedColumn('last_updated', aliasedName, false, + type: const IntType(), requiredDuringInsert: true) + .withConverter($AlbumsTable.$converter0); + final VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + final VerificationMeta _providerTypeMeta = + const VerificationMeta('providerType'); + @override + late final GeneratedColumn providerType = GeneratedColumn( + 'provider_type', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + final VerificationMeta _providerContentMeta = + const VerificationMeta('providerContent'); + @override + late final GeneratedColumn providerContent = + GeneratedColumn('provider_content', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + final VerificationMeta _coverProviderTypeMeta = + const VerificationMeta('coverProviderType'); + @override + late final GeneratedColumn coverProviderType = + GeneratedColumn('cover_provider_type', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + final VerificationMeta _coverProviderContentMeta = + const VerificationMeta('coverProviderContent'); + @override + late final GeneratedColumn coverProviderContent = + GeneratedColumn('cover_provider_content', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + final VerificationMeta _sortProviderTypeMeta = + const VerificationMeta('sortProviderType'); + @override + late final GeneratedColumn sortProviderType = + GeneratedColumn('sort_provider_type', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + final VerificationMeta _sortProviderContentMeta = + const VerificationMeta('sortProviderContent'); + @override + late final GeneratedColumn sortProviderContent = + GeneratedColumn('sort_provider_content', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + @override + List get $columns => [ + rowId, + file, + version, + lastUpdated, + name, + providerType, + providerContent, + coverProviderType, + coverProviderContent, + sortProviderType, + sortProviderContent + ]; + @override + String get aliasedName => _alias ?? 'albums'; + @override + String get actualTableName => 'albums'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('row_id')) { + context.handle( + _rowIdMeta, rowId.isAcceptableOrUnknown(data['row_id']!, _rowIdMeta)); + } + if (data.containsKey('file')) { + context.handle( + _fileMeta, file.isAcceptableOrUnknown(data['file']!, _fileMeta)); + } else if (isInserting) { + context.missing(_fileMeta); + } + if (data.containsKey('version')) { + context.handle(_versionMeta, + version.isAcceptableOrUnknown(data['version']!, _versionMeta)); + } else if (isInserting) { + context.missing(_versionMeta); + } + context.handle(_lastUpdatedMeta, const VerificationResult.success()); + if (data.containsKey('name')) { + context.handle( + _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('provider_type')) { + context.handle( + _providerTypeMeta, + providerType.isAcceptableOrUnknown( + data['provider_type']!, _providerTypeMeta)); + } else if (isInserting) { + context.missing(_providerTypeMeta); + } + if (data.containsKey('provider_content')) { + context.handle( + _providerContentMeta, + providerContent.isAcceptableOrUnknown( + data['provider_content']!, _providerContentMeta)); + } else if (isInserting) { + context.missing(_providerContentMeta); + } + if (data.containsKey('cover_provider_type')) { + context.handle( + _coverProviderTypeMeta, + coverProviderType.isAcceptableOrUnknown( + data['cover_provider_type']!, _coverProviderTypeMeta)); + } else if (isInserting) { + context.missing(_coverProviderTypeMeta); + } + if (data.containsKey('cover_provider_content')) { + context.handle( + _coverProviderContentMeta, + coverProviderContent.isAcceptableOrUnknown( + data['cover_provider_content']!, _coverProviderContentMeta)); + } else if (isInserting) { + context.missing(_coverProviderContentMeta); + } + if (data.containsKey('sort_provider_type')) { + context.handle( + _sortProviderTypeMeta, + sortProviderType.isAcceptableOrUnknown( + data['sort_provider_type']!, _sortProviderTypeMeta)); + } else if (isInserting) { + context.missing(_sortProviderTypeMeta); + } + if (data.containsKey('sort_provider_content')) { + context.handle( + _sortProviderContentMeta, + sortProviderContent.isAcceptableOrUnknown( + data['sort_provider_content']!, _sortProviderContentMeta)); + } else if (isInserting) { + context.missing(_sortProviderContentMeta); + } + return context; + } + + @override + Set get $primaryKey => {rowId}; + @override + Album map(Map data, {String? tablePrefix}) { + return Album.fromData(data, + prefix: tablePrefix != null ? '$tablePrefix.' : null); + } + + @override + $AlbumsTable createAlias(String alias) { + return $AlbumsTable(attachedDatabase, alias); + } + + static TypeConverter $converter0 = + const _DateTimeConverter(); +} + +class AlbumShare extends DataClass implements Insertable { + final int album; + final String userId; + final String? displayName; + final DateTime sharedAt; + AlbumShare( + {required this.album, + required this.userId, + this.displayName, + required this.sharedAt}); + factory AlbumShare.fromData(Map data, {String? prefix}) { + final effectivePrefix = prefix ?? ''; + return AlbumShare( + album: const IntType() + .mapFromDatabaseResponse(data['${effectivePrefix}album'])!, + userId: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}user_id'])!, + displayName: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}display_name']), + sharedAt: $AlbumSharesTable.$converter0.mapToDart(const DateTimeType() + .mapFromDatabaseResponse(data['${effectivePrefix}shared_at']))!, + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['album'] = Variable(album); + map['user_id'] = Variable(userId); + if (!nullToAbsent || displayName != null) { + map['display_name'] = Variable(displayName); + } + { + final converter = $AlbumSharesTable.$converter0; + map['shared_at'] = Variable(converter.mapToSql(sharedAt)!); + } + return map; + } + + AlbumSharesCompanion toCompanion(bool nullToAbsent) { + return AlbumSharesCompanion( + album: Value(album), + userId: Value(userId), + displayName: displayName == null && nullToAbsent + ? const Value.absent() + : Value(displayName), + sharedAt: Value(sharedAt), + ); + } + + factory AlbumShare.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AlbumShare( + album: serializer.fromJson(json['album']), + userId: serializer.fromJson(json['userId']), + displayName: serializer.fromJson(json['displayName']), + sharedAt: serializer.fromJson(json['sharedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'album': serializer.toJson(album), + 'userId': serializer.toJson(userId), + 'displayName': serializer.toJson(displayName), + 'sharedAt': serializer.toJson(sharedAt), + }; + } + + AlbumShare copyWith( + {int? album, + String? userId, + Value displayName = const Value.absent(), + DateTime? sharedAt}) => + AlbumShare( + album: album ?? this.album, + userId: userId ?? this.userId, + displayName: displayName.present ? displayName.value : this.displayName, + sharedAt: sharedAt ?? this.sharedAt, + ); + @override + String toString() { + return (StringBuffer('AlbumShare(') + ..write('album: $album, ') + ..write('userId: $userId, ') + ..write('displayName: $displayName, ') + ..write('sharedAt: $sharedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(album, userId, displayName, sharedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AlbumShare && + other.album == this.album && + other.userId == this.userId && + other.displayName == this.displayName && + other.sharedAt == this.sharedAt); +} + +class AlbumSharesCompanion extends UpdateCompanion { + final Value album; + final Value userId; + final Value displayName; + final Value sharedAt; + const AlbumSharesCompanion({ + this.album = const Value.absent(), + this.userId = const Value.absent(), + this.displayName = const Value.absent(), + this.sharedAt = const Value.absent(), + }); + AlbumSharesCompanion.insert({ + required int album, + required String userId, + this.displayName = const Value.absent(), + required DateTime sharedAt, + }) : album = Value(album), + userId = Value(userId), + sharedAt = Value(sharedAt); + static Insertable custom({ + Expression? album, + Expression? userId, + Expression? displayName, + Expression? sharedAt, + }) { + return RawValuesInsertable({ + if (album != null) 'album': album, + if (userId != null) 'user_id': userId, + if (displayName != null) 'display_name': displayName, + if (sharedAt != null) 'shared_at': sharedAt, + }); + } + + AlbumSharesCompanion copyWith( + {Value? album, + Value? userId, + Value? displayName, + Value? sharedAt}) { + return AlbumSharesCompanion( + album: album ?? this.album, + userId: userId ?? this.userId, + displayName: displayName ?? this.displayName, + sharedAt: sharedAt ?? this.sharedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (album.present) { + map['album'] = Variable(album.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (displayName.present) { + map['display_name'] = Variable(displayName.value); + } + if (sharedAt.present) { + final converter = $AlbumSharesTable.$converter0; + map['shared_at'] = + Variable(converter.mapToSql(sharedAt.value)!); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AlbumSharesCompanion(') + ..write('album: $album, ') + ..write('userId: $userId, ') + ..write('displayName: $displayName, ') + ..write('sharedAt: $sharedAt') + ..write(')')) + .toString(); + } +} + +class $AlbumSharesTable extends AlbumShares + with TableInfo<$AlbumSharesTable, AlbumShare> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AlbumSharesTable(this.attachedDatabase, [this._alias]); + final VerificationMeta _albumMeta = const VerificationMeta('album'); + @override + late final GeneratedColumn album = GeneratedColumn( + 'album', aliasedName, false, + type: const IntType(), + requiredDuringInsert: true, + defaultConstraints: 'REFERENCES albums (row_id) ON DELETE CASCADE'); + final VerificationMeta _userIdMeta = const VerificationMeta('userId'); + @override + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, false, + type: const StringType(), requiredDuringInsert: true); + final VerificationMeta _displayNameMeta = + const VerificationMeta('displayName'); + @override + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', aliasedName, true, + type: const StringType(), requiredDuringInsert: false); + final VerificationMeta _sharedAtMeta = const VerificationMeta('sharedAt'); + @override + late final GeneratedColumnWithTypeConverter sharedAt = + GeneratedColumn('shared_at', aliasedName, false, + type: const IntType(), requiredDuringInsert: true) + .withConverter($AlbumSharesTable.$converter0); + @override + List get $columns => [album, userId, displayName, sharedAt]; + @override + String get aliasedName => _alias ?? 'album_shares'; + @override + String get actualTableName => 'album_shares'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('album')) { + context.handle( + _albumMeta, album.isAcceptableOrUnknown(data['album']!, _albumMeta)); + } else if (isInserting) { + context.missing(_albumMeta); + } + if (data.containsKey('user_id')) { + context.handle(_userIdMeta, + userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); + } else if (isInserting) { + context.missing(_userIdMeta); + } + if (data.containsKey('display_name')) { + context.handle( + _displayNameMeta, + displayName.isAcceptableOrUnknown( + data['display_name']!, _displayNameMeta)); + } + context.handle(_sharedAtMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {album, userId}; + @override + AlbumShare map(Map data, {String? tablePrefix}) { + return AlbumShare.fromData(data, + prefix: tablePrefix != null ? '$tablePrefix.' : null); + } + + @override + $AlbumSharesTable createAlias(String alias) { + return $AlbumSharesTable(attachedDatabase, alias); + } + + static TypeConverter $converter0 = + const _DateTimeConverter(); +} + +abstract class _$SqliteDb extends GeneratedDatabase { + _$SqliteDb(QueryExecutor e) : super(SqlTypeSystem.defaultInstance, e); + _$SqliteDb.connect(DatabaseConnection c) : super.connect(c); + late final $ServersTable servers = $ServersTable(this); + late final $AccountsTable accounts = $AccountsTable(this); + late final $FilesTable files = $FilesTable(this); + late final $AccountFilesTable accountFiles = $AccountFilesTable(this); + late final $ImagesTable images = $ImagesTable(this); + late final $TrashesTable trashes = $TrashesTable(this); + late final $DirFilesTable dirFiles = $DirFilesTable(this); + late final $AlbumsTable albums = $AlbumsTable(this); + late final $AlbumSharesTable albumShares = $AlbumSharesTable(this); + @override + Iterable get allTables => allSchemaEntities.whereType(); + @override + List get allSchemaEntities => [ + servers, + accounts, + files, + accountFiles, + images, + trashes, + dirFiles, + albums, + albumShares + ]; +} diff --git a/app/lib/entity/sqlite_table_converter.dart b/app/lib/entity/sqlite_table_converter.dart new file mode 100644 index 00000000..c134000d --- /dev/null +++ b/app/lib/entity/sqlite_table_converter.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:nc_photos/ci_string.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/cover_provider.dart'; +import 'package:nc_photos/entity/album/provider.dart'; +import 'package:nc_photos/entity/album/sort_provider.dart'; +import 'package:nc_photos/entity/exif.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/sqlite_table.dart' as sql; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; +import 'package:nc_photos/object_extension.dart'; + +class SqliteAlbumConverter { + static Album fromSql( + sql.Album album, File albumFile, List shares) { + return Album( + lastUpdated: album.lastUpdated, + name: album.name, + provider: AlbumProvider.fromJson({ + "type": album.providerType, + "content": jsonDecode(album.providerContent), + }), + coverProvider: AlbumCoverProvider.fromJson({ + "type": album.coverProviderType, + "content": jsonDecode(album.coverProviderContent), + }), + sortProvider: AlbumSortProvider.fromJson({ + "type": album.sortProviderType, + "content": jsonDecode(album.sortProviderContent), + }), + shares: shares.isEmpty + ? null + : shares + .map((e) => AlbumShare( + userId: e.userId.toCi(), + displayName: e.displayName, + sharedAt: e.sharedAt.toUtc(), + )) + .toList(), + albumFile: albumFile, + savedVersion: album.version, + ); + } + + static sql.CompleteAlbumCompanion toSql(Album album, int albumFileRowId) { + final providerJson = album.provider.toJson(); + final coverProviderJson = album.coverProvider.toJson(); + final sortProviderJson = album.sortProvider.toJson(); + final dbAlbum = sql.AlbumsCompanion.insert( + file: albumFileRowId, + version: Album.version, + lastUpdated: album.lastUpdated, + name: album.name, + providerType: providerJson["type"], + providerContent: jsonEncode(providerJson["content"]), + coverProviderType: coverProviderJson["type"], + coverProviderContent: jsonEncode(coverProviderJson["content"]), + sortProviderType: sortProviderJson["type"], + sortProviderContent: jsonEncode(sortProviderJson["content"]), + ); + final dbAlbumShares = album.shares + ?.map((s) => sql.AlbumSharesCompanion( + userId: Value(s.userId.toCaseInsensitiveString()), + displayName: Value(s.displayName), + sharedAt: Value(s.sharedAt), + )) + .toList(); + return sql.CompleteAlbumCompanion(dbAlbum, dbAlbumShares ?? []); + } +} + +class SqliteFileConverter { + static File fromSql(String homeDir, sql.CompleteFile f) { + final metadata = f.image?.run((obj) => Metadata( + lastUpdated: obj.lastUpdated, + fileEtag: obj.fileEtag, + imageWidth: obj.width, + imageHeight: obj.height, + exif: obj.exifRaw?.run((e) => Exif.fromJson(jsonDecode(e))), + )); + return File( + path: "remote.php/dav/files/$homeDir/${f.accountFile.relativePath}", + contentLength: f.file.contentLength, + contentType: f.file.contentType, + etag: f.file.etag, + lastModified: f.file.lastModified, + isCollection: f.file.isCollection, + usedBytes: f.file.usedBytes, + hasPreview: f.file.hasPreview, + fileId: f.file.fileId, + isFavorite: f.accountFile.isFavorite, + ownerId: f.file.ownerId?.toCi(), + trashbinFilename: f.trash?.filename, + trashbinOriginalLocation: f.trash?.originalLocation, + trashbinDeletionTime: f.trash?.deletionTime, + metadata: metadata, + isArchived: f.accountFile.isArchived, + overrideDateTime: f.accountFile.overrideDateTime, + ); + } + + static sql.CompleteFileCompanion toSql(sql.Account? account, File file) { + final dbFile = sql.FilesCompanion( + server: account == null ? const Value.absent() : Value(account.server), + fileId: Value(file.fileId!), + contentLength: Value(file.contentLength), + contentType: Value(file.contentType), + etag: Value(file.etag), + lastModified: Value(file.lastModified), + isCollection: Value(file.isCollection), + usedBytes: Value(file.usedBytes), + hasPreview: Value(file.hasPreview), + ownerId: Value(file.ownerId!.toCaseInsensitiveString()), + ); + final dbAccountFile = sql.AccountFilesCompanion( + account: account == null ? const Value.absent() : Value(account.rowId), + relativePath: Value(file.strippedPathWithEmpty), + isFavorite: Value(file.isFavorite), + isArchived: Value(file.isArchived), + overrideDateTime: Value(file.overrideDateTime), + ); + final dbImage = file.metadata?.run((m) => sql.ImagesCompanion.insert( + lastUpdated: m.lastUpdated, + fileEtag: Value(m.fileEtag), + width: Value(m.imageWidth), + height: Value(m.imageHeight), + exifRaw: Value(m.exif?.toJson().run((j) => jsonEncode(j))), + dateTimeOriginal: Value(m.exif?.dateTimeOriginal), + )); + final dbTrash = file.trashbinDeletionTime == null + ? null + : sql.TrashesCompanion.insert( + filename: file.trashbinFilename!, + originalLocation: file.trashbinOriginalLocation!, + deletionTime: file.trashbinDeletionTime!, + ); + return sql.CompleteFileCompanion(dbFile, dbAccountFile, dbImage, dbTrash); + } +} diff --git a/app/lib/entity/sqlite_table_extension.dart b/app/lib/entity/sqlite_table_extension.dart new file mode 100644 index 00000000..e72fb356 --- /dev/null +++ b/app/lib/entity/sqlite_table_extension.dart @@ -0,0 +1,538 @@ +import 'package:drift/drift.dart'; +import 'package:nc_photos/account.dart' as app; +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'; +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/mobile/platform.dart' + if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform; +import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/platform/k.dart' as platform_k; + +class CompleteFile { + const CompleteFile(this.file, this.accountFile, this.image, this.trash); + + final File file; + final AccountFile accountFile; + final Image? image; + final Trash? trash; +} + +class CompleteFileCompanion { + const CompleteFileCompanion( + this.file, this.accountFile, this.image, this.trash); + + final FilesCompanion file; + final AccountFilesCompanion accountFile; + final ImagesCompanion? image; + final TrashesCompanion? trash; +} + +extension CompleteFileListExtension on List { + Future> convertToAppFile(app.Account account) { + return map((f) => { + "homeDir": account.homeDir.toString(), + "completeFile": f, + }).computeAll(_covertSqliteDbFile); + } +} + +extension FileListExtension on List { + Future> convertToFileCompanion(Account? account) { + return map((f) => { + "account": account, + "file": f, + }).computeAll(_convertAppFile); + } +} + +class AlbumWithShare { + const AlbumWithShare(this.album, this.share); + + final Album album; + final AlbumShare? share; +} + +class CompleteAlbumCompanion { + const CompleteAlbumCompanion(this.album, this.albumShares); + + final AlbumsCompanion album; + final List albumShares; +} + +class AccountFileRowIds { + const AccountFileRowIds( + this.accountFileRowId, this.accountRowId, this.fileRowId); + + final int accountFileRowId; + final int accountRowId; + final int fileRowId; +} + +class AccountFileRowIdsWithFileId { + const AccountFileRowIdsWithFileId( + this.accountFileRowId, this.accountRowId, this.fileRowId, this.fileId); + + final int accountFileRowId; + final int accountRowId; + final int fileRowId; + final int fileId; +} + +extension SqliteDbExtension on SqliteDb { + /// Start a transaction and run [block] + /// + /// The [db] argument passed to [block] is identical to this + /// + /// Do NOT call this when using [isolate], call [useInIsolate] instead + Future use(Future Function(SqliteDb db) block) async { + return await platform.Lock.synchronized(k.appDbLockId, () async { + return await transaction(() async { + return await block(this); + }); + }); + } + + /// Start an isolate and run [callback] there, with access to the + /// SQLite database + Future isolate(T args, ComputeWithDbCallback callback) async { + // we need to acquire the lock here as method channel is not supported in + // background isolates + return await platform.Lock.synchronized(k.appDbLockId, () async { + // in unit tests we use an in-memory db, which mean there's no way to + // access it in other isolates + if (platform_k.isUnitTest) { + return await callback(this, args); + } else { + return await computeWithDb(callback, args); + } + }); + } + + /// Start a transaction and run [block], this version is suitable to be called + /// in [isolate] + /// + /// See: [use] + Future useInIsolate(Future Function(SqliteDb db) block) async { + return await transaction(() async { + return await block(this); + }); + } + + Future insertAccountOf(app.Account account) async { + Server dbServer; + try { + dbServer = await into(servers).insertReturning( + ServersCompanion.insert( + address: account.url, + ), + mode: InsertMode.insertOrIgnore, + ); + } on StateError catch (_) { + // already exists + final query = select(servers) + ..where((t) => t.address.equals(account.url)); + dbServer = await query.getSingle(); + } + await into(accounts).insert( + AccountsCompanion.insert( + server: dbServer.rowId, + userId: account.username.toCaseInsensitiveString(), + ), + mode: InsertMode.insertOrIgnore, + ); + } + + Future accountOf(app.Account account) { + final query = select(accounts).join([ + innerJoin(servers, servers.rowId.equalsExp(accounts.server), + useColumns: false) + ]) + ..where(servers.address.equals(account.url)) + ..where( + accounts.userId.equals(account.username.toCaseInsensitiveString())) + ..limit(1); + return query.map((r) => r.readTable(accounts)).getSingle(); + } + + FilesQueryBuilder queryFiles() => FilesQueryBuilder(this); + + /// Query File by app File + /// + /// Only one of [sqlAccount] and [appAccount] must be passed + Future fileOf( + app.File file, { + Account? sqlAccount, + app.Account? appAccount, + }) { + assert((sqlAccount != null) != (appAccount != null)); + final query = queryFiles().run((q) { + q.setQueryMode(FilesQueryMode.file); + if (sqlAccount != null) { + q.setSqlAccount(sqlAccount); + } else { + q.setAppAccount(appAccount!); + } + if (file.fileId != null) { + q.byFileId(file.fileId!); + } else { + q.byRelativePath(file.strippedPathWithEmpty); + } + return q.build()..limit(1); + }); + return query.map((r) => r.readTable(files)).getSingle(); + } + + /// Query AccountFiles, Accounts and Files row ID by app File + /// + /// Only one of [sqlAccount] and [appAccount] must be passed + Future accountFileRowIdsOfOrNull( + app.File file, { + Account? sqlAccount, + app.Account? appAccount, + }) { + assert((sqlAccount != null) != (appAccount != null)); + final query = queryFiles().run((q) { + q.setQueryMode(FilesQueryMode.expression, expressions: [ + accountFiles.rowId, + accountFiles.account, + accountFiles.file, + ]); + if (sqlAccount != null) { + q.setSqlAccount(sqlAccount); + } else { + q.setAppAccount(appAccount!); + } + if (file.fileId != null) { + q.byFileId(file.fileId!); + } else { + q.byRelativePath(file.strippedPathWithEmpty); + } + return q.build()..limit(1); + }); + return query + .map((r) => AccountFileRowIds( + r.read(accountFiles.rowId)!, + r.read(accountFiles.account)!, + r.read(accountFiles.file)!, + )) + .getSingleOrNull(); + } + + /// See [accountFileRowIdsOfOrNull] + Future accountFileRowIdsOf( + app.File file, { + Account? sqlAccount, + app.Account? appAccount, + }) => + accountFileRowIdsOfOrNull(file, + sqlAccount: sqlAccount, appAccount: appAccount) + .notNull(); + + /// Query AccountFiles, Accounts and Files row ID by fileIds + /// + /// Returned files are NOT guaranteed to be sorted as [fileIds] + Future> accountFileRowIdsByFileIds( + Iterable fileIds, { + Account? sqlAccount, + app.Account? appAccount, + }) { + assert((sqlAccount != null) != (appAccount != null)); + final query = queryFiles().run((q) { + q.setQueryMode(FilesQueryMode.expression, expressions: [ + accountFiles.rowId, + accountFiles.account, + accountFiles.file, + files.fileId, + ]); + if (sqlAccount != null) { + q.setSqlAccount(sqlAccount); + } else { + q.setAppAccount(appAccount!); + } + q.byFileIds(fileIds); + return q.build(); + }); + return query + .map((r) => AccountFileRowIdsWithFileId( + r.read(accountFiles.rowId)!, + r.read(accountFiles.account)!, + r.read(accountFiles.file)!, + r.read(files.fileId)!, + )) + .get(); + } + + /// Query CompleteFile by fileId + /// + /// Returned files are NOT guaranteed to be sorted as [fileIds] + Future> completeFilesByFileIds( + Iterable fileIds, { + Account? sqlAccount, + app.Account? appAccount, + }) { + assert((sqlAccount != null) != (appAccount != null)); + final query = queryFiles().run((q) { + q.setQueryMode(FilesQueryMode.completeFile); + if (sqlAccount != null) { + q.setSqlAccount(sqlAccount); + } else { + q.setAppAccount(appAccount!); + } + q.byFileIds(fileIds); + return q.build(); + }); + return query + .map((r) => CompleteFile( + r.readTable(files), + r.readTable(accountFiles), + r.readTableOrNull(images), + r.readTableOrNull(trashes), + )) + .get(); + } + + Future> completeFilesByDirRowId( + int dirRowId, { + Account? sqlAccount, + app.Account? appAccount, + }) { + assert((sqlAccount != null) != (appAccount != null)); + final query = queryFiles().run((q) { + q.setQueryMode(FilesQueryMode.completeFile); + if (sqlAccount != null) { + q.setSqlAccount(sqlAccount); + } else { + q.setAppAccount(appAccount!); + } + q.byDirRowId(dirRowId); + return q.build(); + }); + return query + .map((r) => CompleteFile( + r.readTable(files), + r.readTable(accountFiles), + r.readTableOrNull(images), + r.readTableOrNull(trashes), + )) + .get(); + } + + /// Query CompleteFile by favorite + Future> completeFilesByFavorite({ + Account? sqlAccount, + app.Account? appAccount, + }) { + assert((sqlAccount != null) != (appAccount != null)); + final query = queryFiles().run((q) { + q.setQueryMode(FilesQueryMode.completeFile); + if (sqlAccount != null) { + q.setSqlAccount(sqlAccount); + } else { + q.setAppAccount(appAccount!); + } + q.byFavorite(true); + return q.build(); + }); + return query + .map((r) => CompleteFile( + r.readTable(files), + r.readTable(accountFiles), + r.readTableOrNull(images), + r.readTableOrNull(trashes), + )) + .get(); + } +} + +enum FilesQueryMode { + file, + completeFile, + expression, +} + +/// Build a Files table query +/// +/// If you call more than one by* methods, the condition will be added up +/// instead of replaced. No validations will be made to make sure the resulting +/// conditions make sense +class FilesQueryBuilder { + FilesQueryBuilder(this.db); + + /// Set the query mode + /// + /// If [mode] == FilesQueryMode.expression, [expressions] must be defined and + /// not empty + void setQueryMode( + FilesQueryMode mode, { + Iterable? expressions, + }) { + assert( + (mode == FilesQueryMode.expression) != (expressions?.isEmpty != false)); + _queryMode = mode; + _selectExpressions = expressions; + } + + void setSqlAccount(Account account) { + assert(_appAccount == null); + _sqlAccount = account; + } + + void setAppAccount(app.Account account) { + assert(_sqlAccount == null); + _appAccount = account; + } + + void setAccountless() { + assert(_sqlAccount == null && _appAccount == null); + _isAccountless = true; + } + + void byRowId(int rowId) { + _byRowId = rowId; + } + + void byFileId(int fileId) { + _byFileId = fileId; + } + + void byFileIds(Iterable fileIds) { + _byFileIds = fileIds; + } + + void byRelativePath(String path) { + _byRelativePath = path; + } + + void byRelativePathPattern(String pattern) { + _byRelativePathPattern = pattern; + } + + void byMimePattern(String pattern) { + (_byMimePatterns ??= []).add(pattern); + } + + void byFavorite(bool favorite) { + _byFavorite = favorite; + } + + void byDirRowId(int dirRowId) { + _byDirRowId = dirRowId; + } + + void byServerRowId(int serverRowId) { + _byServerRowId = serverRowId; + } + + JoinedSelectStatement build() { + if (_sqlAccount == null && _appAccount == null && !_isAccountless) { + throw StateError("Invalid query: missing account"); + } + final dynamic select = _queryMode == FilesQueryMode.expression + ? db.selectOnly(db.files) + : db.select(db.files); + final query = select.join([ + innerJoin(db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId), + useColumns: _queryMode == FilesQueryMode.completeFile), + if (_appAccount != null) ...[ + innerJoin( + db.accounts, db.accounts.rowId.equalsExp(db.accountFiles.account), + useColumns: false), + innerJoin(db.servers, db.servers.rowId.equalsExp(db.accounts.server), + useColumns: false), + ], + if (_byDirRowId != null) + innerJoin(db.dirFiles, db.dirFiles.child.equalsExp(db.files.rowId), + useColumns: false), + if (_queryMode == FilesQueryMode.completeFile) ...[ + leftOuterJoin( + db.images, db.images.accountFile.equalsExp(db.accountFiles.rowId)), + leftOuterJoin(db.trashes, db.trashes.file.equalsExp(db.files.rowId)), + ], + ]) as JoinedSelectStatement; + if (_queryMode == FilesQueryMode.expression) { + query.addColumns(_selectExpressions!); + } + + if (_sqlAccount != null) { + query.where(db.accountFiles.account.equals(_sqlAccount!.rowId)); + } else if (_appAccount != null) { + query + ..where(db.servers.address.equals(_appAccount!.url)) + ..where(db.accounts.userId + .equals(_appAccount!.username.toCaseInsensitiveString())); + } + + if (_byRowId != null) { + query.where(db.files.rowId.equals(_byRowId)); + } + if (_byFileId != null) { + query.where(db.files.fileId.equals(_byFileId)); + } + if (_byFileIds != null) { + query.where(db.files.fileId.isIn(_byFileIds!)); + } + if (_byRelativePath != null) { + query.where(db.accountFiles.relativePath.equals(_byRelativePath)); + } + if (_byRelativePathPattern != null) { + query.where(db.accountFiles.relativePath.like(_byRelativePathPattern!)); + } + if (_byMimePatterns?.isNotEmpty == true) { + final expression = _byMimePatterns!.sublist(1).fold>( + db.files.contentType.like(_byMimePatterns![0]), + (previousValue, element) => + previousValue | db.files.contentType.like(element)); + query.where(expression); + } + if (_byFavorite != null) { + if (_byFavorite!) { + query.where(db.accountFiles.isFavorite.equals(true)); + } else { + // null are treated as false + query.where(db.accountFiles.isFavorite.equals(true).not()); + } + } + if (_byDirRowId != null) { + query.where(db.dirFiles.dir.equals(_byDirRowId)); + } + if (_byServerRowId != null) { + query.where(db.files.server.equals(_byServerRowId)); + } + return query; + } + + final SqliteDb db; + + FilesQueryMode _queryMode = FilesQueryMode.file; + Iterable? _selectExpressions; + + Account? _sqlAccount; + app.Account? _appAccount; + bool _isAccountless = false; + + int? _byRowId; + int? _byFileId; + Iterable? _byFileIds; + String? _byRelativePath; + String? _byRelativePathPattern; + List? _byMimePatterns; + bool? _byFavorite; + int? _byDirRowId; + int? _byServerRowId; +} + +app.File _covertSqliteDbFile(Map map) { + final homeDir = map["homeDir"] as String; + final file = map["completeFile"] as CompleteFile; + return SqliteFileConverter.fromSql(homeDir, file); +} + +CompleteFileCompanion _convertAppFile(Map map) { + final account = map["account"] as Account?; + final file = map["file"] as app.File; + return SqliteFileConverter.toSql(account, file); +} diff --git a/app/lib/entity/sqlite_table_isolate.dart b/app/lib/entity/sqlite_table_isolate.dart new file mode 100644 index 00000000..9eb48d3e --- /dev/null +++ b/app/lib/entity/sqlite_table_isolate.dart @@ -0,0 +1,87 @@ +import 'dart:isolate'; + +import 'package:drift/drift.dart'; +import 'package:drift/isolate.dart'; +import 'package:flutter/foundation.dart'; +import 'package:nc_photos/entity/sqlite_table.dart'; +import 'package:nc_photos/mobile/platform.dart' + if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform; + +typedef ComputeWithDbCallback = Future Function( + SqliteDb db, T message); + +/// Create a drift db running in an isolate +/// +/// This is only expected to be used in the main isolate +Future createDb() async { + // see: https://drift.simonbinder.eu/docs/advanced-features/isolates/ + final driftIsolate = await _createDriftIsolate(); + final connection = await driftIsolate.connect(); + return SqliteDb.connect(connection); +} + +Future computeWithDb( + ComputeWithDbCallback callback, T args) async { + return await compute( + _computeWithDbImpl, + _ComputeWithDbMessage( + await platform.getSqliteConnectionArgs(), callback, args), + ); +} + +class _IsolateStartRequest { + const _IsolateStartRequest(this.sendDriftIsolate, this.platformArgs); + + final SendPort sendDriftIsolate; + final Map platformArgs; +} + +class _ComputeWithDbMessage { + const _ComputeWithDbMessage( + this.sqliteConnectionArgs, this.callback, this.args); + + final Map sqliteConnectionArgs; + final ComputeWithDbCallback callback; + final T args; +} + +Future _createDriftIsolate() async { + final args = await platform.getSqliteConnectionArgs(); + final receivePort = ReceivePort(); + await Isolate.spawn( + _startBackground, + _IsolateStartRequest(receivePort.sendPort, args), + ); + // _startBackground will send the DriftIsolate to this ReceivePort + return await receivePort.first as DriftIsolate; +} + +void _startBackground(_IsolateStartRequest request) { + // this is the entry point from the background isolate! Let's create + // the database from the path we received + final executor = platform.openSqliteConnectionWithArgs(request.platformArgs); + // we're using DriftIsolate.inCurrent here as this method already runs on a + // background isolate. If we used DriftIsolate.spawn, a third isolate would be + // started which is not what we want! + final driftIsolate = DriftIsolate.inCurrent( + () => DatabaseConnection.fromExecutor(executor), + // this breaks background service! + serialize: false, + ); + // inform the starting isolate about this, so that it can call .connect() + request.sendDriftIsolate.send(driftIsolate); +} + +Future _computeWithDbImpl(_ComputeWithDbMessage message) async { + // we don't use driftIsolate because opening a DB normally is found to perform + // better + final sqliteDb = SqliteDb( + executor: + platform.openSqliteConnectionWithArgs(message.sqliteConnectionArgs), + ); + try { + return await message.callback(sqliteDb, message.args); + } finally { + await sqliteDb.close(); + } +} diff --git a/app/lib/future_util.dart b/app/lib/future_util.dart new file mode 100644 index 00000000..39c03ad5 --- /dev/null +++ b/app/lib/future_util.dart @@ -0,0 +1,33 @@ +import 'dart:async'; + +import 'package:nc_photos/iterable_extension.dart'; + +Future> waitOr( + Iterable> futures, + T Function(Object error, StackTrace? stackTrace) onError, +) async { + final completer = Completer>(); + final results = List.filled(futures.length, null); + var remaining = results.length; + if (remaining == 0) { + return Future.value(const []); + } + + void onResult() { + if (--remaining <= 0) { + // finished + completer.complete(results.cast()); + } + } + + for (final p in futures.withIndex()) { + p.item2.then((value) { + results[p.item1] = value; + onResult(); + }).onError((error, stackTrace) { + results[p.item1] = onError(error!, stackTrace); + onResult(); + }); + } + return completer.future; +} diff --git a/app/lib/iterable_extension.dart b/app/lib/iterable_extension.dart index f253e157..93a624ba 100644 --- a/app/lib/iterable_extension.dart +++ b/app/lib/iterable_extension.dart @@ -67,8 +67,13 @@ extension IterableExtension on Iterable { } Future> computeAll(ComputeCallback callback) async { - return await compute( - _computeAllImpl, _ComputeAllMessage(callback, asList())); + final list = asList(); + if (list.isEmpty) { + return []; + } else { + return await compute( + _computeAllImpl, _ComputeAllMessage(callback, list)); + } } /// Return a list containing elements in this iterable diff --git a/app/lib/list_util.dart b/app/lib/list_util.dart index 7334d767..e426c1dc 100644 --- a/app/lib/list_util.dart +++ b/app/lib/list_util.dart @@ -9,7 +9,7 @@ import 'package:tuple/tuple.dart'; /// The first returned list contains items exist in [b] but not [a], the second /// returned list contains items exist in [a] but not [b] Tuple2, List> diffWith( - List a, List b, int Function(T a, T b) comparator) { + Iterable a, Iterable b, int Function(T a, T b) comparator) { final aIt = a.iterator, bIt = b.iterator; final aMissing = [], bMissing = []; while (true) { @@ -31,7 +31,8 @@ Tuple2, List> diffWith( } } -Tuple2, List> diff(List a, List b) => +Tuple2, List> diff( + Iterable a, Iterable b) => diffWith(a, b, Comparable.compare); Tuple2, List> _diffUntilEqual( diff --git a/app/lib/main.dart b/app/lib/main.dart index 20fee5bc..a8e8cbf6 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -9,7 +9,7 @@ import 'package:nc_photos/widget/my_app.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - await app_init.initAppLaunch(); + await app_init.init(app_init.InitIsolateType.main); if (platform_k.isMobile) { // reset orientation override just in case, see #59 diff --git a/app/lib/metadata_task_manager.dart b/app/lib/metadata_task_manager.dart index cb7608c3..0fd46d60 100644 --- a/app/lib/metadata_task_manager.dart +++ b/app/lib/metadata_task_manager.dart @@ -4,9 +4,8 @@ import 'package:event_bus/event_bus.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/pref.dart'; @@ -14,7 +13,9 @@ import 'package:nc_photos/use_case/update_missing_metadata.dart'; /// Task to update metadata for missing files class MetadataTask { - MetadataTask(this.account, this.pref); + MetadataTask(this._c, this.account, this.pref) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo); @override toString() { @@ -28,12 +29,11 @@ class MetadataTask { final shareFolder = File(path: file_util.unstripPath(account, pref.getShareFolderOr())); bool hasScanShareFolder = false; - final fileRepo = FileRepo(FileCachedDataSource(AppDb())); for (final r in account.roots) { final dir = File(path: file_util.unstripPath(account, r)); hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir); final op = UpdateMissingMetadata( - fileRepo, const _UpdateMissingMetadataConfigProvider()); + _c.fileRepo, const _UpdateMissingMetadataConfigProvider()); await for (final _ in op(account, dir)) { if (!Pref().isEnableExifOr()) { _log.info("[call] EXIF disabled, task ending immaturely"); @@ -44,7 +44,7 @@ class MetadataTask { } if (!hasScanShareFolder) { final op = UpdateMissingMetadata( - fileRepo, const _UpdateMissingMetadataConfigProvider()); + _c.fileRepo, const _UpdateMissingMetadataConfigProvider()); await for (final _ in op( account, shareFolder, @@ -65,6 +65,8 @@ class MetadataTask { } } + final DiContainer _c; + final Account account; final AccountPref pref; diff --git a/app/lib/mobile/db_util.dart b/app/lib/mobile/db_util.dart index 806c78e8..45592ea7 100644 --- a/app/lib/mobile/db_util.dart +++ b/app/lib/mobile/db_util.dart @@ -1,4 +1,31 @@ -import 'package:idb_sqflite/idb_sqflite.dart'; -import 'package:sqflite/sqflite.dart'; +import 'dart:io' as dart; -IdbFactory getDbFactory() => getIdbFactorySqflite(databaseFactory); +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:path/path.dart' as path_lib; +import 'package:path_provider/path_provider.dart'; + +Future> getSqliteConnectionArgs() async { + // put the database file, called db.sqlite here, into the documents folder + // for your app. + final dbFolder = await getApplicationDocumentsDirectory(); + return { + "path": path_lib.join(dbFolder.path, "db.sqlite"), + }; +} + +QueryExecutor openSqliteConnectionWithArgs(Map args) { + final file = dart.File(args["path"]); + return NativeDatabase( + file, + // logStatements: true, + ); +} + +QueryExecutor openSqliteConnection() { + // the LazyDatabase util lets us find the right location for the file async. + return LazyDatabase(() async { + final args = await getSqliteConnectionArgs(); + return openSqliteConnectionWithArgs(args); + }); +} diff --git a/app/lib/platform/k.dart b/app/lib/platform/k.dart index f0deb7c1..9853cdb6 100644 --- a/app/lib/platform/k.dart +++ b/app/lib/platform/k.dart @@ -8,3 +8,4 @@ final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS); final isAndroid = !kIsWeb && Platform.isAndroid; final isDesktop = !kIsWeb && (Platform.isLinux || Platform.isMacOS || Platform.isWindows); +final isUnitTest = !kIsWeb && Platform.environment.containsKey("FLUTTER_TEST"); diff --git a/app/lib/service.dart b/app/lib/service.dart index 8f5fa8a9..88a36225 100644 --- a/app/lib/service.dart +++ b/app/lib/service.dart @@ -3,13 +3,13 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations_en.dart'; +import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_init.dart' as app_init; import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/event/native_event.dart'; @@ -69,7 +69,7 @@ class _Service { final service = FlutterBackgroundService(); service.setForegroundMode(true); - await app_init.initAppLaunch(); + await app_init.init(app_init.InitIsolateType.service); await _L10n().init(); _log.info("[call] Service started"); @@ -87,6 +87,7 @@ class _Service { } onCancelSubscription.cancel(); onDataSubscription.cancel(); + await KiwiContainer().resolve().sqliteDb.close(); service.stopBackgroundService(); _log.info("[call] Service stopped"); } @@ -235,12 +236,12 @@ class _MetadataTask { final shareFolder = File( path: file_util.unstripPath(account, accountPref.getShareFolderOr())); bool hasScanShareFolder = false; - final fileRepo = FileRepo(FileCachedDataSource(AppDb())); + final c = KiwiContainer().resolve(); for (final r in account.roots) { final dir = File(path: file_util.unstripPath(account, r)); hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir); final updater = UpdateMissingMetadata( - fileRepo, const _UpdateMissingMetadataConfigProvider()); + c.fileRepo, const _UpdateMissingMetadataConfigProvider()); void onServiceStop() { _log.info("[_updateMetadata] Stopping task: user canceled"); updater.stop(); @@ -263,7 +264,7 @@ class _MetadataTask { } if (!hasScanShareFolder) { final shareUpdater = UpdateMissingMetadata( - fileRepo, const _UpdateMissingMetadataConfigProvider()); + c.fileRepo, const _UpdateMissingMetadataConfigProvider()); void onServiceStop() { _log.info("[_updateMetadata] Stopping task: user canceled"); shareUpdater.stop(); diff --git a/app/lib/share_handler.dart b/app/lib/share_handler.dart index a3e98395..a9cb50a9 100644 --- a/app/lib/share_handler.dart +++ b/app/lib/share_handler.dart @@ -6,12 +6,10 @@ import 'package:flutter/services.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/share/data_source.dart'; @@ -179,9 +177,9 @@ class ShareHandler { clearSelection?.call(); isSelectionCleared = true; - final fileRepo = FileRepo(FileCachedDataSource(AppDb())); - final path = await _createDir(fileRepo, account, result.albumName); - await _copyFilesToDir(fileRepo, account, files, path); + final c = KiwiContainer().resolve(); + final path = await _createDir(c.fileRepo, account, result.albumName); + await _copyFilesToDir(c.fileRepo, account, files, path); controller?.close(); return _shareFileAsLink( account, diff --git a/app/lib/use_case/add_to_album.dart b/app/lib/use_case/add_to_album.dart index d115b794..a239faf6 100644 --- a/app/lib/use_case/add_to_album.dart +++ b/app/lib/use_case/add_to_album.dart @@ -17,12 +17,12 @@ import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; class AddToAlbum { AddToAlbum(this._c) : assert(require(_c)), - assert(ListShare.require(_c)); + assert(ListShare.require(_c)), + assert(PreProcessAlbum.require(_c)); static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo) && - DiContainer.has(c, DiType.shareRepo) && - DiContainer.has(c, DiType.appDb); + DiContainer.has(c, DiType.shareRepo); /// Add a list of AlbumItems to [album] Future call( @@ -30,7 +30,7 @@ class AddToAlbum { _log.info("[call] Add ${items.length} items to album '${album.name}'"); assert(album.provider is AlbumStaticProvider); // resync is needed to work out album cover and latest item - final oldItems = await PreProcessAlbum(_c.appDb)(account, album); + final oldItems = await PreProcessAlbum(_c)(account, album); final itemSet = oldItems .map((e) => OverrideComparator( e, _isItemFileEqual, _getItemHashCode)) diff --git a/app/lib/use_case/cache_favorite.dart b/app/lib/use_case/cache_favorite.dart index bf84419a..2a95ec25 100644 --- a/app/lib/use_case/cache_favorite.dart +++ b/app/lib/use_case/cache_favorite.dart @@ -1,12 +1,12 @@ +import 'package:drift/drift.dart' as sql; import 'package:event_bus/event_bus.dart'; -import 'package:idb_shim/idb_client.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; -import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/sqlite_table.dart' as sql; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/list_util.dart' as list_util; @@ -17,7 +17,7 @@ class CacheFavorite { : assert(require(_c)), assert(ListFavoriteOffline.require(_c)); - static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb); + static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb); /// Cache favorites Future call( @@ -34,39 +34,45 @@ class CacheFavorite { final newFavorites = result.item1; final removedFavorites = result.item2.map((f) => f.copyWith(isFavorite: false)).toList(); - if (newFavorites.isEmpty && removedFavorites.isEmpty) { + final newFileIds = newFavorites.map((f) => f.fileId!).toList(); + final removedFileIds = removedFavorites.map((f) => f.fileId!).toList(); + if (newFileIds.isEmpty && removedFileIds.isEmpty) { return; } - await _c.appDb.use( - (db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite), - (transaction) async { - final fileStore = transaction.objectStore(AppDb.file2StoreName); - await Future.wait(newFavorites.map((f) async { - _log.info("[call] New favorite: ${f.path}"); + await _c.sqliteDb.use((db) async { + final rowIds = await db.accountFileRowIdsByFileIds( + newFileIds + removedFileIds, + appAccount: account, + ); + final rowIdsMap = + Map.fromEntries(rowIds.map((e) => MapEntry(e.fileId, e))); + await db.batch((batch) { + for (final id in newFileIds) { try { - await fileStore.put(AppDbFile2Entry.fromFile(account, f).toJson(), - AppDbFile2Entry.toPrimaryKeyForFile(account, f)); + batch.update( + db.accountFiles, + const sql.AccountFilesCompanion(isFavorite: sql.Value(true)), + where: (sql.$AccountFilesTable t) => + t.rowId.equals(rowIdsMap[id]!.accountFileRowId), + ); } catch (e, stackTrace) { - _log.shout( - "[call] Failed while writing new favorite to AppDb: ${logFilename(f.path)}", - e, - stackTrace); + _log.shout("[call] File not found in DB: $id", e, stackTrace); } - })); - await Future.wait(removedFavorites.map((f) async { - _log.info("[call] Remove favorite: ${f.path}"); + } + for (final id in removedFileIds) { try { - await fileStore.put(AppDbFile2Entry.fromFile(account, f).toJson(), - AppDbFile2Entry.toPrimaryKeyForFile(account, f)); + batch.update( + db.accountFiles, + const sql.AccountFilesCompanion(isFavorite: sql.Value(null)), + where: (sql.$AccountFilesTable t) => + t.rowId.equals(rowIdsMap[id]!.accountFileRowId), + ); } catch (e, stackTrace) { - _log.shout( - "[call] Failed while writing removed favorite to AppDb: ${logFilename(f.path)}", - e, - stackTrace); + _log.shout("[call] File not found in DB: $id", e, stackTrace); } - })); - }, - ); + } + }); + }); KiwiContainer() .resolve() diff --git a/app/lib/use_case/compat/v37.dart b/app/lib/use_case/compat/v37.dart deleted file mode 100644 index 64cc58cf..00000000 --- a/app/lib/use_case/compat/v37.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:idb_shim/idb_client.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/app_db.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/iterable_extension.dart'; -import 'package:nc_photos/object_extension.dart'; -import 'package:path/path.dart' as path_lib; - -/// Compatibility helper for v37 -class CompatV37 { - static Future setAppDbMigrationFlag(AppDb appDb) async { - _log.info("[setAppDbMigrationFlag] Set db flag"); - try { - await appDb.use( - (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), - (transaction) async { - final metaStore = transaction.objectStore(AppDb.metaStoreName); - await metaStore - .put(const AppDbMetaEntryCompatV37(false).toEntry().toJson()); - await transaction.completed; - }, - ); - } catch (e, stackTrace) { - _log.shout( - "[setAppDbMigrationFlag] Failed while setting db flag, drop db instead", - e, - stackTrace); - await appDb.delete(); - } - } - - static Future isAppDbNeedMigration(AppDb appDb) async { - final dbItem = await appDb.use( - (db) => db.transaction(AppDb.metaStoreName, idbModeReadOnly), - (transaction) async { - final metaStore = transaction.objectStore(AppDb.metaStoreName); - return await metaStore.getObject(AppDbMetaEntryCompatV37.key) as Map?; - }, - ); - if (dbItem == null) { - return false; - } - try { - final dbEntry = AppDbMetaEntry.fromJson(dbItem.cast()); - final compatV37 = AppDbMetaEntryCompatV37.fromJson(dbEntry.obj); - return !compatV37.isMigrated; - } catch (e, stackTrace) { - _log.shout("[isAppDbNeedMigration] Failed", e, stackTrace); - return true; - } - } - - static Future migrateAppDb(AppDb appDb) async { - _log.info("[migrateAppDb] Migrate AppDb"); - try { - await appDb.use( - (db) => db.transaction( - [AppDb.file2StoreName, AppDb.dirStoreName, AppDb.metaStoreName], - idbModeReadWrite), - (transaction) async { - final noMediaFiles = <_NoMediaFile>[]; - try { - final fileStore = transaction.objectStore(AppDb.file2StoreName); - final dirStore = transaction.objectStore(AppDb.dirStoreName); - // scan the db to see which dirs contain a no media marker - await for (final c in fileStore.openCursor()) { - final item = c.value as Map; - final strippedPath = item["strippedPath"] as String; - if (file_util.isNoMediaMarkerPath(strippedPath)) { - noMediaFiles.add(_NoMediaFile( - item["server"], - item["userId"], - path_lib - .dirname(item["strippedPath"]) - .run((p) => p == "." ? "" : p), - item["file"]["fileId"], - )); - } - c.next(); - } - // sort to make sure parent dirs are always in front of sub dirs - noMediaFiles - .sort((a, b) => a.strippedDirPath.compareTo(b.strippedDirPath)); - _log.info( - "[migrateAppDb] nomedia dirs: ${noMediaFiles.toReadableString()}"); - - if (noMediaFiles.isNotEmpty) { - await _migrateAppDbFileStore(appDb, noMediaFiles, - fileStore: fileStore); - await _migrateAppDbDirStore(appDb, noMediaFiles, - dirStore: dirStore); - } - - final metaStore = transaction.objectStore(AppDb.metaStoreName); - await metaStore - .put(const AppDbMetaEntryCompatV37(true).toEntry().toJson()); - } catch (_) { - transaction.abort(); - rethrow; - } - }, - ); - } catch (e, stackTrace) { - _log.shout("[migrateAppDb] Failed while migrating, drop db instead", e, - stackTrace); - await appDb.delete(); - rethrow; - } - } - - /// Remove files under no media dirs - static Future _migrateAppDbFileStore( - AppDb appDb, - List<_NoMediaFile> noMediaFiles, { - required ObjectStore fileStore, - }) async { - await for (final c in fileStore.openCursor()) { - final item = c.value as Map; - final under = noMediaFiles.firstWhereOrNull((e) { - if (e.server != item["server"] || e.userId != item["userId"]) { - return false; - } - final prefix = e.strippedDirPath.isEmpty ? "" : "${e.strippedDirPath}/"; - final itemDir = path_lib - .dirname(item["strippedPath"]) - .run((p) => p == "." ? "" : p); - // check isNotEmpty to prevent user root being removed when the - // marker is placed in root - return item["strippedPath"].isNotEmpty && - item["strippedPath"].startsWith(prefix) && - // keep no media marker in top-most dir - !(itemDir == e.strippedDirPath && - file_util.isNoMediaMarkerPath(item["strippedPath"])); - }); - if (under != null) { - _log.fine("[_migrateAppDbFileStore] Remove db entry: ${c.primaryKey}"); - await c.delete(); - } - c.next(); - } - } - - /// Remove dirs under no media dirs - static Future _migrateAppDbDirStore( - AppDb appDb, - List<_NoMediaFile> noMediaFiles, { - required ObjectStore dirStore, - }) async { - await for (final c in dirStore.openCursor()) { - final item = c.value as Map; - final under = noMediaFiles.firstWhereOrNull((e) { - if (e.server != item["server"] || e.userId != item["userId"]) { - return false; - } - final prefix = e.strippedDirPath.isEmpty ? "" : "${e.strippedDirPath}/"; - return item["strippedPath"].startsWith(prefix) || - e.strippedDirPath == item["strippedPath"]; - }); - if (under != null) { - if (under.strippedDirPath == item["strippedPath"]) { - // this dir contains the no media marker - // remove all children, keep only the marker - final newChildren = (item["children"] as List) - .where((childId) => childId == under.fileId) - .toList(); - if (newChildren.isEmpty) { - // ??? - _log.severe( - "[_migrateAppDbDirStore] Marker not found in dir: ${item["strippedPath"]}"); - // drop this dir - await c.delete(); - } - _log.fine( - "[_migrateAppDbDirStore] Migrate db entry: ${c.primaryKey}"); - await c.update(Map.of(item).apply((obj) { - obj["children"] = newChildren; - })); - } else { - // this dir is a sub dir - // drop this dir - _log.fine("[_migrateAppDbDirStore] Remove db entry: ${c.primaryKey}"); - await c.delete(); - } - } - c.next(); - } - } - - static final _log = Logger("use_case.compat.v37.CompatV37"); -} - -class _NoMediaFile { - const _NoMediaFile( - this.server, this.userId, this.strippedDirPath, this.fileId); - - @override - toString() => "$runtimeType {" - "server: $server, " - "userId: $userId, " - "strippedDirPath: $strippedDirPath, " - "fileId: $fileId, " - "}"; - - final String server; - // no need to use CiString as all strings are stored with the same casing in - // db - final String userId; - final String strippedDirPath; - final int fileId; -} diff --git a/app/lib/use_case/db_compat/v5.dart b/app/lib/use_case/db_compat/v5.dart deleted file mode 100644 index d0434c2b..00000000 --- a/app/lib/use_case/db_compat/v5.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:idb_shim/idb_client.dart'; -import 'package:logging/logging.dart'; -import 'package:nc_photos/app_db.dart'; -import 'package:nc_photos/ci_string.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/object_extension.dart'; - -class DbCompatV5 { - static Future isNeedMigration(AppDb appDb) async { - final dbItem = await appDb.use( - (db) => db.transaction(AppDb.metaStoreName, idbModeReadOnly), - (transaction) async { - final metaStore = transaction.objectStore(AppDb.metaStoreName); - return await metaStore.getObject(AppDbMetaEntryDbCompatV5.key) as Map?; - }, - ); - if (dbItem == null) { - return false; - } - try { - final dbEntry = AppDbMetaEntry.fromJson(dbItem.cast()); - final compatV35 = AppDbMetaEntryDbCompatV5.fromJson(dbEntry.obj); - return !compatV35.isMigrated; - } catch (e, stackTrace) { - _log.shout("[isNeedMigration] Failed", e, stackTrace); - return true; - } - } - - static Future migrate(AppDb appDb) async { - _log.info("[migrate] Migrate AppDb"); - try { - await appDb.use( - (db) => db.transaction( - [AppDb.file2StoreName, AppDb.metaStoreName], idbModeReadWrite), - (transaction) async { - try { - final fileStore = transaction.objectStore(AppDb.file2StoreName); - await for (final c in fileStore.openCursor()) { - final item = c.value as Map; - // migrate file entry: add bestDateTime - final fileEntry = item.cast().run((json) { - final f = File.fromJson(json["file"].cast()); - return AppDbFile2Entry( - json["server"], - (json["userId"] as String).toCi(), - json["strippedPath"], - f.bestDateTime.millisecondsSinceEpoch, - File.fromJson(json["file"].cast()), - ); - }); - await c.update(fileEntry.toJson()); - - c.next(); - } - final metaStore = transaction.objectStore(AppDb.metaStoreName); - await metaStore - .put(const AppDbMetaEntryDbCompatV5(true).toEntry().toJson()); - } catch (_) { - transaction.abort(); - rethrow; - } - }, - ); - } catch (e, stackTrace) { - _log.shout( - "[migrate] Failed while migrating, drop db instead", e, stackTrace); - await appDb.delete(); - rethrow; - } - } - - static final _log = Logger("use_case.db_compat.v5.DbCompatV5"); -} diff --git a/app/lib/use_case/find_file.dart b/app/lib/use_case/find_file.dart index 0120c30a..eca16279 100644 --- a/app/lib/use_case/find_file.dart +++ b/app/lib/use_case/find_file.dart @@ -1,14 +1,14 @@ -import 'package:flutter/foundation.dart'; -import 'package:idb_shim/idb_client.dart'; +import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; +import 'package:nc_photos/iterable_extension.dart'; class FindFile { FindFile(this._c) : assert(require(_c)); - static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb); + static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb); /// Find list of files in the DB by [fileIds] /// @@ -19,37 +19,34 @@ class FindFile { List fileIds, { void Function(int fileId)? onFileNotFound, }) async { - final dbItems = await _c.appDb.use( - (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), - (transaction) async { - final fileStore = transaction.objectStore(AppDb.file2StoreName); - return await Future.wait(fileIds.map((id) => - fileStore.getObject(AppDbFile2Entry.toPrimaryKey(account, id)))); - }, - ); - final fileMap = await compute(_covertFileMap, dbItems); - final files = []; - for (final id in fileIds) { - final f = fileMap[id]; - if (f == null) { - if (onFileNotFound == null) { - throw StateError("File ID not found: $id"); - } else { - onFileNotFound(id); - } - } else { - files.add(f); - } + _log.info("[call] fileIds: ${fileIds.toReadableString()}"); + final dbFiles = await _c.sqliteDb.use((db) async { + return await db.completeFilesByFileIds(fileIds, appAccount: account); + }); + final files = await dbFiles.convertToAppFile(account); + final fileMap = {}; + for (final f in files) { + fileMap[f.fileId!] = f; } - return files; + + return () sync* { + for (final id in fileIds) { + final f = fileMap[id]; + if (f == null) { + if (onFileNotFound == null) { + throw StateError("File ID not found: $id"); + } else { + onFileNotFound(id); + } + } else { + yield fileMap[id]!; + } + } + }() + .toList(); } final DiContainer _c; -} -Map _covertFileMap(List dbItems) { - return Map.fromEntries(dbItems - .whereType() - .map((j) => AppDbFile2Entry.fromJson(j.cast()).file) - .map((f) => MapEntry(f.fileId!, f))); + static final _log = Logger("use_case.find_file.FindFile"); } diff --git a/app/lib/use_case/list_album.dart b/app/lib/use_case/list_album.dart index 81ba9bae..97e8ebf2 100644 --- a/app/lib/use_case/list_album.dart +++ b/app/lib/use_case/list_album.dart @@ -1,4 +1,4 @@ -import 'package:logging/logging.dart'; +import 'package:collection/collection.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; @@ -50,30 +50,22 @@ class ListAlbum { yield ExceptionEvent(e, stackTrace); return; } - final albumFiles = + final List albumFiles = ls.where((element) => element.isCollection != true).toList(); + // migrate files for (var i = 0; i < albumFiles.length; ++i) { - var f = albumFiles[i]; + var f = albumFiles[i]!; try { if (CompatV25.isAlbumFileNeedMigration(f)) { - f = await CompatV25.migrateAlbumFile(_c, account, f); + albumFiles[i] = await CompatV25.migrateAlbumFile(_c, account, f); } - albumFiles[i] = f; - yield await _c.albumRepo.get(account, f); } catch (e, stackTrace) { yield ExceptionEvent(e, stackTrace); + albumFiles[i] = null; } } - try { - _c.albumRepo.cleanUp( - account, remote_storage_util.getRemoteAlbumsDir(account), albumFiles); - } catch (e, stacktrace) { - // not important, log and ignore - _log.shout("[_call] Failed while cleanUp", e, stacktrace); - } + yield* _c.albumRepo.getAll(account, albumFiles.whereNotNull().toList()); } final DiContainer _c; - - static final _log = Logger("use_case.list_album.ListAlbum"); } diff --git a/app/lib/use_case/list_favorite_offline.dart b/app/lib/use_case/list_favorite_offline.dart index f5a0b8da..b9075876 100644 --- a/app/lib/use_case/list_favorite_offline.dart +++ b/app/lib/use_case/list_favorite_offline.dart @@ -1,43 +1,19 @@ -import 'package:idb_shim/idb_client.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/use_case/find_file.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; class ListFavoriteOffline { - ListFavoriteOffline(this._c) - : assert(require(_c)), - assert(FindFile.require(_c)); + ListFavoriteOffline(this._c) : assert(require(_c)); - static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb); + static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb); /// List all favorites for [account] from the local DB - Future> call(Account account) { - final rootDirs = account.roots - .map((r) => File(path: file_util.unstripPath(account, r))) - .toList(); - return _c.appDb.use( - (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), - (transaction) async { - final fileStore = transaction.objectStore(AppDb.file2StoreName); - final fileIsFavoriteIndex = - fileStore.index(AppDbFile2Entry.fileIsFavoriteIndexName); - return await fileIsFavoriteIndex - .openCursor( - key: AppDbFile2Entry.toFileIsFavoriteIndexKey(account, true), - autoAdvance: true, - ) - .map((c) => AppDbFile2Entry.fromJson( - (c.value as Map).cast())) - .map((e) => e.file) - .where((f) => - file_util.isSupportedFormat(f) && - rootDirs.any((r) => file_util.isOrUnderDir(f, r))) - .toList(); - }, - ); + Future> call(Account account) async { + final dbFiles = await _c.sqliteDb.use((db) async { + return await db.completeFilesByFavorite(appAccount: account); + }); + return await dbFiles.convertToAppFile(account); } final DiContainer _c; diff --git a/app/lib/use_case/populate_album.dart b/app/lib/use_case/populate_album.dart index 1e1fb4e0..3c5dc6c9 100644 --- a/app/lib/use_case/populate_album.dart +++ b/app/lib/use_case/populate_album.dart @@ -1,7 +1,6 @@ import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; @@ -15,7 +14,9 @@ import 'package:nc_photos/use_case/list_tagged_file.dart'; import 'package:nc_photos/use_case/scan_dir.dart'; class PopulateAlbum { - const PopulateAlbum(this.appDb); + PopulateAlbum(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo); Future> call(Account account, Album album) async { if (album.provider is AlbumStaticProvider) { @@ -40,7 +41,7 @@ class PopulateAlbum { final provider = album.provider as AlbumDirProvider; final products = []; for (final d in provider.dirs) { - final stream = ScanDir(FileRepo(FileCachedDataSource(appDb)))(account, d); + final stream = ScanDir(_c.fileRepo)(account, d); await for (final result in stream) { if (result is ExceptionEvent) { _log.shout( @@ -81,7 +82,7 @@ class PopulateAlbum { final date = DateTime(provider.year, provider.month, provider.day); final from = date.subtract(const Duration(days: 2)); final to = date.add(const Duration(days: 3)); - final files = await FileAppDbDataSource(appDb).listByDate( + final files = await FileSqliteDbDataSource(_c).listByDate( account, from.millisecondsSinceEpoch, to.millisecondsSinceEpoch); return files .where((f) => file_util.isSupportedFormat(f)) @@ -93,7 +94,7 @@ class PopulateAlbum { .toList(); } - final AppDb appDb; + final DiContainer _c; static final _log = Logger("use_case.populate_album.PopulateAlbum"); } diff --git a/app/lib/use_case/populate_person.dart b/app/lib/use_case/populate_person.dart index fb02589f..1d1aeca1 100644 --- a/app/lib/use_case/populate_person.dart +++ b/app/lib/use_case/populate_person.dart @@ -1,51 +1,38 @@ -import 'package:idb_shim/idb_client.dart'; +import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/face.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; class PopulatePerson { - const PopulatePerson(this.appDb); + PopulatePerson(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb); /// Return a list of files of the faces Future> call(Account account, List faces) async { - return await appDb.use( - (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), - (transaction) async { - final store = transaction.objectStore(AppDb.file2StoreName); - final products = []; - for (final f in faces) { - try { - products.add(await _populateOne(account, f, fileStore: store)); - } catch (e, stackTrace) { - _log.severe("[call] Failed populating file of face: ${f.fileId}", e, - stackTrace); + final fileIds = faces.map((f) => f.fileId).toList(); + final dbFiles = await _c.sqliteDb.use((db) async { + return await db.completeFilesByFileIds(fileIds, appAccount: account); + }); + final files = await dbFiles.convertToAppFile(account); + final fileMap = Map.fromEntries(files.map((f) => MapEntry(f.fileId, f))); + return faces + .map((f) { + final file = fileMap[f.fileId]; + if (file == null) { + _log.warning( + "[call] File doesn't exist in DB, removed?: ${f.fileId}"); } - } - return products; - }, - ); + return file; + }) + .whereNotNull() + .toList(); } - Future _populateOne( - Account account, - Face face, { - required ObjectStore fileStore, - }) async { - final dbItem = await fileStore - .getObject(AppDbFile2Entry.toPrimaryKey(account, face.fileId)) as Map?; - if (dbItem == null) { - _log.warning( - "[_populateOne] File doesn't exist in DB, removed?: '${face.fileId}'"); - throw CacheNotFoundException(); - } - final dbEntry = AppDbFile2Entry.fromJson(dbItem.cast()); - return dbEntry.file; - } + final DiContainer _c; - final AppDb appDb; - - static final _log = Logger("use_case.populate_album.PopulatePerson"); + static final _log = Logger("use_case.populate_person.PopulatePerson"); } diff --git a/app/lib/use_case/preprocess_album.dart b/app/lib/use_case/preprocess_album.dart index fd2db685..52d924db 100644 --- a/app/lib/use_case/preprocess_album.dart +++ b/app/lib/use_case/preprocess_album.dart @@ -1,5 +1,5 @@ import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; @@ -12,19 +12,24 @@ import 'package:nc_photos/use_case/resync_album.dart'; /// - with AlbumStaticProvider: [ResyncAlbum] /// - with AlbumDynamicProvider/AlbumSmartProvider: [PopulateAlbum] class PreProcessAlbum { - const PreProcessAlbum(this.appDb); + PreProcessAlbum(this._c) + : assert(require(_c)), + assert(PopulateAlbum.require(_c)), + assert(ResyncAlbum.require(_c)); + + static bool require(DiContainer c) => true; Future> call(Account account, Album album) { if (album.provider is AlbumStaticProvider) { - return ResyncAlbum(appDb)(account, album); + return ResyncAlbum(_c)(account, album); } else if (album.provider is AlbumDynamicProvider || album.provider is AlbumSmartProvider) { - return PopulateAlbum(appDb)(account, album); + return PopulateAlbum(_c)(account, album); } else { throw ArgumentError( "Unknown album provider: ${album.provider.runtimeType}"); } } - final AppDb appDb; + final DiContainer _c; } diff --git a/app/lib/use_case/remove_from_album.dart b/app/lib/use_case/remove_from_album.dart index 44ba39b1..ca0bc235 100644 --- a/app/lib/use_case/remove_from_album.dart +++ b/app/lib/use_case/remove_from_album.dart @@ -15,10 +15,10 @@ import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; class RemoveFromAlbum { RemoveFromAlbum(this._c) : assert(require(_c)), - assert(UnshareFileFromAlbum.require(_c)); + assert(UnshareFileFromAlbum.require(_c)), + assert(PreProcessAlbum.require(_c)); - static bool require(DiContainer c) => - DiContainer.has(c, DiType.albumRepo) && DiContainer.has(c, DiType.appDb); + static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo); /// Remove a list of AlbumItems from [album] /// @@ -91,7 +91,7 @@ class RemoveFromAlbum { _log.info( "[_fixAlbumPostRemove] Resync as interesting item is being removed"); // need to update the album properties - final newItemsSynced = await PreProcessAlbum(_c.appDb)(account, newAlbum); + final newItemsSynced = await PreProcessAlbum(_c)(account, newAlbum); newAlbum = await UpdateAlbumWithActualItems(null)( account, newAlbum, diff --git a/app/lib/use_case/resync_album.dart b/app/lib/use_case/resync_album.dart index dca7bb3a..f3f1ed5f 100644 --- a/app/lib/use_case/resync_album.dart +++ b/app/lib/use_case/resync_album.dart @@ -1,17 +1,19 @@ -import 'package:flutter/foundation.dart'; -import 'package:idb_shim/idb_client.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/debug_util.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; -import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/use_case/find_file.dart'; /// Resync files inside an album with the file db class ResyncAlbum { - const ResyncAlbum(this.appDb); + ResyncAlbum(this._c) + : assert(require(_c)), + assert(FindFile.require(_c)); + + static bool require(DiContainer c) => true; Future> call(Account account, Album album) async { _log.info("[call] Resync album: ${album.name}"); @@ -20,22 +22,27 @@ class ResyncAlbum { "Resync only make sense for static albums: ${album.name}"); } final items = AlbumStaticProvider.of(album).items; - final dbItems = await appDb.use( - (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), - (transaction) async { - final store = transaction.objectStore(AppDb.file2StoreName); - return await Future.wait(items.whereType().map((i) => - store.getObject( - AppDbFile2Entry.toPrimaryKey(account, i.file.fileId!)))); - }, + + final files = await FindFile(_c)( + account, + items.whereType().map((i) => i.file.fileId!).toList(), + onFileNotFound: (_) {}, ); - final fileMap = await compute(_covertFileMap, dbItems); + final fileIt = files.iterator; + var nextFile = fileIt.moveNext() ? fileIt.current : null; return items.map((i) { if (i is AlbumFileItem) { try { - return i.copyWith( - file: fileMap[i.file.fileId]!, - ); + if (i.file.fileId! == nextFile?.fileId) { + final newItem = i.copyWith( + file: nextFile, + ); + nextFile = fileIt.moveNext() ? fileIt.current : null; + return newItem; + } else { + _log.warning("[call] File not found: ${logFilename(i.file.path)}"); + return i; + } } catch (e, stackTrace) { _log.shout( "[call] Failed syncing file in album: ${logFilename(i.file.path)}", @@ -49,14 +56,7 @@ class ResyncAlbum { }).toList(); } - final AppDb appDb; + final DiContainer _c; static final _log = Logger("use_case.resync_album.ResyncAlbum"); } - -Map _covertFileMap(List dbItems) { - return Map.fromEntries(dbItems - .whereType() - .map((j) => AppDbFile2Entry.fromJson(j.cast()).file) - .map((f) => MapEntry(f.fileId!, f))); -} diff --git a/app/lib/use_case/scan_dir_offline.dart b/app/lib/use_case/scan_dir_offline.dart index dbc3655f..6d0e129b 100644 --- a/app/lib/use_case/scan_dir_offline.dart +++ b/app/lib/use_case/scan_dir_offline.dart @@ -1,50 +1,60 @@ -import 'package:idb_shim/idb_client.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/entity/sqlite_table_converter.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; +import 'package:nc_photos/object_extension.dart'; class ScanDirOffline { ScanDirOffline(this._c) : assert(require(_c)); - static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb); + static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb); - /// List all files under a dir recursively from the local DB - Future> call( + Future> call( Account account, File root, { bool isOnlySupportedFormat = true, }) async { - final dbItems = await _c.appDb.use( - (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), - (transaction) async { - final store = transaction.objectStore(AppDb.file2StoreName); - final index = store.index(AppDbFile2Entry.strippedPathIndexName); - final range = KeyRange.bound( - AppDbFile2Entry.toStrippedPathIndexLowerKeyForDir(account, root), - AppDbFile2Entry.toStrippedPathIndexUpperKeyForDir(account, root), - ); - return await index - .openCursor(range: range, autoAdvance: false) - .map((c) { - final v = c.value as Map; - c.next(); - return v; - }).toList(); - }, - ); - final results = await dbItems.computeAll(_covertAppDbFile2Entry); - if (isOnlySupportedFormat) { - return results.where((f) => file_util.isSupportedFormat(f)); - } else { - return results; - } + return await _c.sqliteDb.isolate({ + "account": account, + "root": root, + "isOnlySupportedFormat": isOnlySupportedFormat, + }, (db, Map args) async { + final Account account = args["account"]; + final File root = args["root"]; + final bool isOnlySupportedFormat = args["isOnlySupportedFormat"]; + final dbFiles = await db.useInIsolate((db) async { + final query = db.queryFiles().run((q) { + q + ..setQueryMode(sql.FilesQueryMode.completeFile) + ..setAppAccount(account); + root.strippedPathWithEmpty.run((p) { + if (p.isNotEmpty) { + q.byRelativePathPattern("$p/%"); + } + }); + if (isOnlySupportedFormat) { + q + ..byMimePattern("image/%") + ..byMimePattern("video/%"); + } + return q.build(); + }); + return await query + .map((r) => sql.CompleteFile( + r.readTable(db.files), + r.readTable(db.accountFiles), + r.readTableOrNull(db.images), + r.readTableOrNull(db.trashes), + )) + .get(); + }); + return dbFiles + .map( + (f) => SqliteFileConverter.fromSql(account.homeDir.toString(), f)) + .toList(); + }); } final DiContainer _c; } - -File _covertAppDbFile2Entry(Map json) => - AppDbFile2Entry.fromJson(json.cast()).file; diff --git a/app/lib/web/db_util.dart b/app/lib/web/db_util.dart index dcd90c07..f86846a7 100644 --- a/app/lib/web/db_util.dart +++ b/app/lib/web/db_util.dart @@ -1,4 +1,30 @@ -import 'package:idb_shim/idb_browser.dart'; -import 'package:idb_shim/idb_shim.dart'; +import 'package:drift/drift.dart'; +import 'package:drift/wasm.dart'; +import 'package:http/http.dart' as http; +import 'package:sqlite3/wasm.dart'; -IdbFactory getDbFactory() => idbFactoryBrowser; +Future> getSqliteConnectionArgs() async => {}; + +QueryExecutor openSqliteConnectionWithArgs(Map args) => + openSqliteConnection(); + +QueryExecutor openSqliteConnection() { + return LazyDatabase(() async { + // Load wasm bundle + final response = await http.get(Uri.parse("sqlite3.wasm")); + // Create a virtual file system backed by IndexedDb with everything in + // `/drift/my_app/` being persisted. + final fs = await IndexedDbFileSystem.open(dbName: "nc-photos"); + final sqlite3 = await WasmSqlite3.load( + response.bodyBytes, + SqliteEnvironment(fileSystem: fs), + ); + + // Then, open a database inside that persisted folder. + return WasmDatabase( + sqlite3: sqlite3, + path: "/drift/nc-photos/app.db", + // logStatements: true, + ); + }); +} diff --git a/app/lib/widget/album_browser.dart b/app/lib/widget/album_browser.dart index bd25afdc..a992cdf4 100644 --- a/app/lib/widget/album_browser.dart +++ b/app/lib/widget/album_browser.dart @@ -4,7 +4,6 @@ import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/download_handler.dart'; @@ -82,6 +81,15 @@ class _AlbumBrowserState extends State SelectableItemStreamListMixin, DraggableItemListMixin, AlbumBrowserMixin { + _AlbumBrowserState() { + final c = KiwiContainer().resolve(); + assert(require(c)); + assert(PreProcessAlbum.require(c)); + _c = c; + } + + static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo); + @override initState() { super.initState(); @@ -156,11 +164,10 @@ class _AlbumBrowserState extends State if (newAlbum.copyWith(lastUpdated: OrNull(_album!.lastUpdated)) != _album) { _log.info("[doneEditMode] Album modified: $newAlbum"); - final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb())); setState(() { _album = newAlbum; }); - UpdateAlbum(albumRepo)( + UpdateAlbum(_c.albumRepo)( widget.account, newAlbum, ).catchError((e, stackTrace) { @@ -184,15 +191,14 @@ class _AlbumBrowserState extends State } Future _initAlbum() async { - final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb())); - var album = await albumRepo.get(widget.account, widget.album.albumFile!); + var album = await _c.albumRepo.get(widget.account, widget.album.albumFile!); if (widget.album.shares?.isNotEmpty == true) { try { - final file = await LsSingleFile(KiwiContainer().resolve())( - widget.account, album.albumFile!.path); + final file = + await LsSingleFile(_c)(widget.account, album.albumFile!.path); if (file.etag != album.albumFile!.etag) { _log.info("[_initAlbum] Album modified in remote, forcing download"); - album = await albumRepo.get(widget.account, File(path: file.path)); + album = await _c.albumRepo.get(widget.account, File(path: file.path)); } } catch (e, stackTrace) { _log.warning("[_initAlbum] Failed while syncing remote album file", e, @@ -815,7 +821,7 @@ class _AlbumBrowserState extends State Future _setAlbum(Album album) async { assert(album.provider is AlbumStaticProvider); - final items = await PreProcessAlbum(AppDb())(widget.account, album); + final items = await PreProcessAlbum(_c)(widget.account, album); if (album.albumFile!.isOwned(widget.account.username)) { album = await _updateAlbumPostResync(album, items); } @@ -835,8 +841,7 @@ class _AlbumBrowserState extends State Future _updateAlbumPostResync( Album album, List items) async { - final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb())); - return await UpdateAlbumWithActualItems(albumRepo)( + return await UpdateAlbumWithActualItems(_c.albumRepo)( widget.account, album, items); } @@ -866,6 +871,8 @@ class _AlbumBrowserState extends State static List _getAlbumItemsOf(Album a) => AlbumStaticProvider.of(a).items; + late final DiContainer _c; + Album? _album; var _sortedItems = []; var _backingFiles = []; diff --git a/app/lib/widget/album_browser_mixin.dart b/app/lib/widget/album_browser_mixin.dart index ff5e1340..505c342d 100644 --- a/app/lib/widget/album_browser_mixin.dart +++ b/app/lib/widget/album_browser_mixin.dart @@ -3,12 +3,12 @@ import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/cover_provider.dart'; +import 'package:nc_photos/entity/album/data_source.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/notified_action.dart'; import 'package:nc_photos/pref.dart'; @@ -197,10 +197,11 @@ mixin AlbumBrowserMixin Future _onUnsetCoverPressed(Account account, Album album) async { _log.info("[_onUnsetCoverPressed] Unset album cover for '${album.name}'"); + final c = KiwiContainer().resolve(); try { await NotifiedAction( () async { - final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb())); + final albumRepo = AlbumRepo(AlbumCachedDataSource(c)); await UpdateAlbum(albumRepo)( account, album.copyWith( diff --git a/app/lib/widget/album_importer.dart b/app/lib/widget/album_importer.dart index 6bea7450..0410cc47 100644 --- a/app/lib/widget/album_importer.dart +++ b/app/lib/widget/album_importer.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/list_importable_album.dart'; import 'package:nc_photos/di_container.dart'; @@ -54,6 +53,15 @@ class AlbumImporter extends StatefulWidget { } class _AlbumImporterState extends State { + _AlbumImporterState() { + final c = KiwiContainer().resolve(); + assert(require(c)); + assert(PreProcessAlbum.require(c)); + _c = c; + } + + static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo); + @override initState() { super.initState(); @@ -236,12 +244,11 @@ class _AlbumImporterState extends State { ); _log.info("[_createAllAlbums] Creating dir album: $album"); - final items = await PreProcessAlbum(AppDb())(widget.account, album); + final items = await PreProcessAlbum(_c)(widget.account, album); album = await UpdateAlbumWithActualItems(null)( widget.account, album, items); - final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb())); - await CreateAlbum(albumRepo)(widget.account, album); + await CreateAlbum(_c.albumRepo)(widget.account, album); } catch (e, stacktrace) { _log.shout( "[_createAllAlbums] Failed creating dir album", e, stacktrace); @@ -260,6 +267,7 @@ class _AlbumImporterState extends State { .toList(); } + late final DiContainer _c; late ListImportableAlbumBloc _bloc; var _backingFiles = []; diff --git a/app/lib/widget/archive_browser.dart b/app/lib/widget/archive_browser.dart index 8d1d4bcf..02c26e62 100644 --- a/app/lib/widget/archive_browser.dart +++ b/app/lib/widget/archive_browser.dart @@ -2,15 +2,15 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/scan_account_dir.dart'; import 'package:nc_photos/compute_queue.dart'; import 'package:nc_photos/debug_util.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file/data_source.dart'; 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; @@ -237,11 +237,11 @@ class _ArchiveBrowserState extends State setState(() { clearSelectedItems(); }); - final fileRepo = FileRepo(FileCachedDataSource(AppDb())); + final c = KiwiContainer().resolve(); final failures = []; for (final f in selectedFiles) { try { - await UpdateProperty(fileRepo) + await UpdateProperty(c.fileRepo) .updateIsArchived(widget.account, f, false); } catch (e, stacktrace) { _log.shout( diff --git a/app/lib/widget/dynamic_album_browser.dart b/app/lib/widget/dynamic_album_browser.dart index 6990b8fb..c90b5962 100644 --- a/app/lib/widget/dynamic_album_browser.dart +++ b/app/lib/widget/dynamic_album_browser.dart @@ -4,7 +4,6 @@ import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; @@ -76,6 +75,15 @@ class _DynamicAlbumBrowserState extends State with SelectableItemStreamListMixin, AlbumBrowserMixin { + _DynamicAlbumBrowserState() { + final c = KiwiContainer().resolve(); + assert(require(c)); + assert(PreProcessAlbum.require(c)); + _c = c; + } + + static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo); + @override initState() { super.initState(); @@ -139,11 +147,10 @@ class _DynamicAlbumBrowserState extends State if (newAlbum.copyWith(lastUpdated: OrNull(_album!.lastUpdated)) != _album) { _log.info("[doneEditMode] Album modified: $newAlbum"); - final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb())); setState(() { _album = newAlbum; }); - UpdateAlbum(albumRepo)( + UpdateAlbum(_c.albumRepo)( widget.account, newAlbum, ).catchError((e, stackTrace) { @@ -171,7 +178,7 @@ class _DynamicAlbumBrowserState extends State final List items; final Album album; try { - items = await PreProcessAlbum(AppDb())(widget.account, widget.album); + items = await PreProcessAlbum(_c)(widget.account, widget.album); album = await _updateAlbumPostPopulate(widget.album, items); } catch (e, stackTrace) { _log.severe("[_initAlbum] Failed while PreProcessAlbum", e, stackTrace); @@ -350,9 +357,8 @@ class _DynamicAlbumBrowserState extends State } _log.info( "[_onConvertBasicPressed] Converting album '${_album!.name}' to static"); - final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb())); try { - await UpdateAlbum(albumRepo)( + await UpdateAlbum(_c.albumRepo)( widget.account, _album!.copyWith( provider: AlbumStaticProvider( @@ -632,11 +638,12 @@ class _DynamicAlbumBrowserState extends State Future _updateAlbumPostPopulate( Album album, List items) async { - final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb())); - return await UpdateAlbumWithActualItems(albumRepo)( + return await UpdateAlbumWithActualItems(_c.albumRepo)( widget.account, album, items); } + late final DiContainer _c; + Album? _album; var _sortedItems = []; var _backingFiles = []; diff --git a/app/lib/widget/home.dart b/app/lib/widget/home.dart index 8b64dc1f..fa349965 100644 --- a/app/lib/widget/home.dart +++ b/app/lib/widget/home.dart @@ -5,6 +5,7 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/data_source.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/k.dart' as k; diff --git a/app/lib/widget/home_photos.dart b/app/lib/widget/home_photos.dart index f60cef83..ecebc4c3 100644 --- a/app/lib/widget/home_photos.dart +++ b/app/lib/widget/home_photos.dart @@ -737,8 +737,9 @@ class _Web { } void startMetadataTask(int missingMetadataCount) { + final c = KiwiContainer().resolve(); MetadataTaskManager().addTask(MetadataTask( - state.widget.account, AccountPref.of(state.widget.account))); + c, state.widget.account, AccountPref.of(state.widget.account))); _metadataTaskProcessTotalCount = missingMetadataCount; } diff --git a/app/lib/widget/new_album_dialog.dart b/app/lib/widget/new_album_dialog.dart index f297eb52..76afc72f 100644 --- a/app/lib/widget/new_album_dialog.dart +++ b/app/lib/widget/new_album_dialog.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; @@ -37,6 +36,14 @@ class NewAlbumDialog extends StatefulWidget { } class _NewAlbumDialogState extends State { + _NewAlbumDialogState() { + final c = KiwiContainer().resolve(); + assert(require(c)); + _c = c; + } + + static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo); + @override initState() { super.initState(); @@ -139,8 +146,7 @@ class _NewAlbumDialogState extends State { sortProvider: const AlbumTimeSortProvider(isAscending: false), ); _log.info("[_onConfirmStaticAlbum] Creating static album: $album"); - final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb())); - final newAlbum = await CreateAlbum(albumRepo)(widget.account, album); + final newAlbum = await CreateAlbum(_c.albumRepo)(widget.account, album); Navigator.of(context).pop(newAlbum); } catch (e, stacktrace) { _log.shout("[_onConfirmStaticAlbum] Failed", e, stacktrace); @@ -173,8 +179,7 @@ class _NewAlbumDialogState extends State { sortProvider: const AlbumTimeSortProvider(isAscending: false), ); _log.info("[_onConfirmDirAlbum] Creating dir album: $album"); - final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb())); - final newAlbum = await CreateAlbum(albumRepo)(widget.account, album); + final newAlbum = await CreateAlbum(_c.albumRepo)(widget.account, album); Navigator.of(context).pop(newAlbum); } catch (e, stacktrace) { _log.shout("[_onConfirmDirAlbum] Failed", e, stacktrace); @@ -220,6 +225,8 @@ class _NewAlbumDialogState extends State { } } + late final DiContainer _c; + final _formKey = GlobalKey(); var _provider = _Provider.static; diff --git a/app/lib/widget/person_browser.dart b/app/lib/widget/person_browser.dart index 24bef6b2..c9da4da1 100644 --- a/app/lib/widget/person_browser.dart +++ b/app/lib/widget/person_browser.dart @@ -8,7 +8,6 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/list_face.dart'; import 'package:nc_photos/cache_manager_util.dart'; @@ -76,6 +75,14 @@ class PersonBrowser extends StatefulWidget { class _PersonBrowserState extends State with SelectableItemStreamListMixin { + _PersonBrowserState() { + final c = KiwiContainer().resolve(); + assert(require(c)); + _c = c; + } + + static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb); + @override initState() { super.initState(); @@ -414,7 +421,7 @@ class _PersonBrowserState extends State } void _transformItems(List items) async { - final files = await PopulatePerson(AppDb())(widget.account, items); + final files = await PopulatePerson(_c)(widget.account, items); _backingFiles = files .sorted(compareFileDateTimeDescending) .where((element) => @@ -442,6 +449,8 @@ class _PersonBrowserState extends State _bloc.add(ListFaceBlocQuery(widget.account, widget.person)); } + late final DiContainer _c; + final ListFaceBloc _bloc = ListFaceBloc(); List? _backingFiles; diff --git a/app/lib/widget/settings.dart b/app/lib/widget/settings.dart index 6887ef83..c45b04ce 100644 --- a/app/lib/widget/settings.dart +++ b/app/lib/widget/settings.dart @@ -12,9 +12,9 @@ import 'package:nc_photos/language_util.dart' as language_util; import 'package:nc_photos/mobile/android/android_info.dart'; import 'package:nc_photos/mobile/platform.dart' if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform; +import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/platform/notification.dart'; -import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/service.dart'; import 'package:nc_photos/snack_bar_manager.dart'; diff --git a/app/lib/widget/share_album_dialog.dart b/app/lib/widget/share_album_dialog.dart index 5b7b6e40..504cf69f 100644 --- a/app/lib/widget/share_album_dialog.dart +++ b/app/lib/widget/share_album_dialog.dart @@ -5,7 +5,6 @@ import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:mutex/mutex.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/async_util.dart' as async_util; import 'package:nc_photos/bloc/list_sharee.dart'; @@ -13,8 +12,6 @@ import 'package:nc_photos/bloc/search_suggestion.dart'; import 'package:nc_photos/ci_string.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/share.dart'; -import 'package:nc_photos/entity/share/data_source.dart'; import 'package:nc_photos/entity/sharee.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; @@ -41,6 +38,16 @@ class ShareAlbumDialog extends StatefulWidget { } class _ShareAlbumDialogState extends State { + _ShareAlbumDialogState() { + final c = KiwiContainer().resolve(); + assert(require(c)); + _c = c; + } + + static bool require(DiContainer c) => + DiContainer.has(c, DiType.albumRepo) && + DiContainer.has(c, DiType.shareRepo); + @override initState() { super.initState(); @@ -227,12 +234,10 @@ class _ShareAlbumDialogState extends State { } Future _createShare(Sharee sharee) async { - final shareRepo = ShareRepo(ShareRemoteDataSource()); - final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb())); var hasFailure = false; try { _album = await _editMutex.protect(() async { - return await ShareAlbumWithUser(shareRepo, albumRepo)( + return await ShareAlbumWithUser(_c.shareRepo, _c.albumRepo)( widget.account, _album, sharee, @@ -320,6 +325,8 @@ class _ShareAlbumDialogState extends State { } } + late final DiContainer _c; + late final _shareeBloc = ListShareeBloc.of(widget.account); final _suggestionBloc = SearchSuggestionBloc( itemToKeywords: (item) => [item.shareWith, item.label.toCi()], diff --git a/app/lib/widget/sharing_browser.dart b/app/lib/widget/sharing_browser.dart index 518343c7..c3c79b27 100644 --- a/app/lib/widget/sharing_browser.dart +++ b/app/lib/widget/sharing_browser.dart @@ -13,6 +13,7 @@ import 'package:nc_photos/bloc/list_sharing.dart'; import 'package:nc_photos/cache_manager_util.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/data_source.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/share.dart'; diff --git a/app/lib/widget/sign_in.dart b/app/lib/widget/sign_in.dart index c61122ec..5d8ab68d 100644 --- a/app/lib/widget/sign_in.dart +++ b/app/lib/widget/sign_in.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/ci_string.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/help_utils.dart' as help_utils; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/platform/k.dart' as platform_k; @@ -253,6 +256,10 @@ class _SignInState extends State { return; } // we've got a good account + final c = KiwiContainer().resolve(); + await c.sqliteDb.use((db) async { + await db.insertAccountOf(account!); + }); // only signing in with app password would trigger distinct final accounts = (Pref().getAccounts3Or([])..add(account)).distinct(); try { diff --git a/app/lib/widget/smart_album_browser.dart b/app/lib/widget/smart_album_browser.dart index f94a884b..8b0937e6 100644 --- a/app/lib/widget/smart_album_browser.dart +++ b/app/lib/widget/smart_album_browser.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/download_handler.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/item.dart'; @@ -60,6 +61,12 @@ class _SmartAlbumBrowserState extends State with SelectableItemStreamListMixin, AlbumBrowserMixin { + _SmartAlbumBrowserState() { + final c = KiwiContainer().resolve(); + assert(PreProcessAlbum.require(c)); + _c = c; + } + @override initState() { super.initState(); @@ -89,7 +96,7 @@ class _SmartAlbumBrowserState extends State Future _initAlbum() async { assert(widget.album.provider is AlbumSmartProvider); _log.info("[_initAlbum] ${widget.album}"); - final items = await PreProcessAlbum(AppDb())(widget.account, widget.album); + final items = await PreProcessAlbum(_c)(widget.account, widget.album); if (mounted) { setState(() { _album = widget.album; @@ -314,6 +321,8 @@ class _SmartAlbumBrowserState extends State .toList(); } + late final DiContainer _c; + Album? _album; var _sortedItems = []; var _backingFiles = []; diff --git a/app/lib/widget/splash.dart b/app/lib/widget/splash.dart index deedb577..bd3da4a9 100644 --- a/app/lib/widget/splash.dart +++ b/app/lib/widget/splash.dart @@ -1,20 +1,15 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/changelog.dart' as changelog; -import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/mobile/android/activity.dart'; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/pref.dart'; -import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/compat/v29.dart'; -import 'package:nc_photos/use_case/compat/v37.dart'; -import 'package:nc_photos/use_case/db_compat/v5.dart'; import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/processing_dialog.dart'; import 'package:nc_photos/widget/setup.dart'; @@ -47,7 +42,6 @@ class _SplashState extends State { if (_shouldUpgrade()) { await _handleUpgrade(); } - await _migrateDb(); _initTimedExit(); } @@ -162,10 +156,6 @@ class _SplashState extends State { showUpdateDialog(); await _upgrade29(lastVersion); } - if (lastVersion < 370) { - showUpdateDialog(); - await _upgrade37(lastVersion); - } if (isShowDialog) { Navigator.of(context).pop(); } @@ -181,53 +171,6 @@ class _SplashState extends State { } } - Future _upgrade37(int lastVersion) async { - final c = KiwiContainer().resolve(); - return CompatV37.setAppDbMigrationFlag(c.appDb); - } - - Future _migrateDb() async { - bool isShowDialog = false; - void showUpdateDialog() { - if (!isShowDialog) { - isShowDialog = true; - showDialog( - context: context, - builder: (_) => ProcessingDialog( - text: L10n.global().migrateDatabaseProcessingNotification, - ), - ); - } - } - - final c = KiwiContainer().resolve(); - if (await DbCompatV5.isNeedMigration(c.appDb)) { - showUpdateDialog(); - try { - await DbCompatV5.migrate(c.appDb); - } catch (_) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().migrateDatabaseFailureNotification), - duration: k.snackBarDurationNormal, - )); - } - } - if (await CompatV37.isAppDbNeedMigration(c.appDb)) { - showUpdateDialog(); - try { - await CompatV37.migrateAppDb(c.appDb); - } catch (_) { - SnackBarManager().showSnackBar(SnackBar( - content: Text(L10n.global().migrateDatabaseFailureNotification), - duration: k.snackBarDurationNormal, - )); - } - } - if (isShowDialog) { - Navigator.of(context).pop(); - } - } - String _gatherChangelog(int from) { if (from < 100) { from *= 10; diff --git a/app/lib/widget/viewer_detail_pane.dart b/app/lib/widget/viewer_detail_pane.dart index d00c6837..29f92cee 100644 --- a/app/lib/widget/viewer_detail_pane.dart +++ b/app/lib/widget/viewer_detail_pane.dart @@ -7,7 +7,6 @@ import 'package:intl/intl.dart'; import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; @@ -17,7 +16,6 @@ import 'package:nc_photos/entity/album/cover_provider.dart'; import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/notified_action.dart'; import 'package:nc_photos/platform/features.dart' as features; @@ -59,6 +57,16 @@ class ViewerDetailPane extends StatefulWidget { } class _ViewerDetailPaneState extends State { + _ViewerDetailPaneState() { + final c = KiwiContainer().resolve(); + assert(require(c)); + _c = c; + } + + static bool require(DiContainer c) => + DiContainer.has(c, DiType.fileRepo) && + DiContainer.has(c, DiType.albumRepo); + @override initState() { super.initState(); @@ -380,8 +388,7 @@ class _ViewerDetailPaneState extends State { try { await NotifiedAction( () async { - final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb())); - await UpdateAlbum(albumRepo)( + await UpdateAlbum(_c.albumRepo)( widget.account, widget.album!.copyWith( coverProvider: AlbumManualCoverProvider( @@ -427,8 +434,7 @@ class _ViewerDetailPaneState extends State { try { await NotifiedAction( () async { - final fileRepo = FileRepo(FileCachedDataSource(AppDb())); - await UpdateProperty(fileRepo) + await UpdateProperty(_c.fileRepo) .updateIsArchived(widget.account, widget.file, false); if (mounted) { Navigator.of(context).pop(); @@ -464,9 +470,8 @@ class _ViewerDetailPaneState extends State { if (value == null || value is! DateTime) { return; } - final fileRepo = FileRepo(FileCachedDataSource(AppDb())); try { - await UpdateProperty(fileRepo) + await UpdateProperty(_c.fileRepo) .updateOverrideDateTime(widget.account, widget.file, value); if (mounted) { setState(() { @@ -520,6 +525,8 @@ class _ViewerDetailPaneState extends State { return false; } + late final DiContainer _c; + late DateTime _dateTime; // EXIF data String? _model; diff --git a/app/pubspec.lock b/app/pubspec.lock index 01eae0bf..a3f78862 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -15,6 +15,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.2.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.0" android_intent_plus: dependency: "direct main" description: @@ -108,6 +115,62 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" + build_config: + dependency: transitive + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.9" + build_runner: + dependency: "direct dev" + description: + name: build_runner + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.11" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + url: "https://pub.dartlang.org" + source: hosted + version: "7.2.3" + built_collection: + dependency: transitive + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "8.3.3" cached_network_image: dependency: "direct main" description: @@ -143,6 +206,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + cli_util: + dependency: transitive + description: + name: cli_util + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.5" clock: dependency: transitive description: @@ -150,6 +227,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" collection: dependency: "direct main" description: @@ -227,6 +311,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.17.2" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.3" dbus: dependency: transitive description: @@ -299,6 +390,20 @@ packages: url: "https://gitlab.com/nc-photos/flutter-draggable-scrollbar" source: git version: "0.1.0" + drift: + dependency: "direct main" + description: + name: drift + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.1" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" equatable: dependency: "direct main" description: @@ -336,6 +441,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.2" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" flutter: dependency: "direct main" description: flutter @@ -467,6 +579,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.2.0" + graphs: + dependency: transitive + description: + name: graphs + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" hashcodes: dependency: transitive description: @@ -502,20 +621,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.0.1" - idb_shim: - dependency: "direct main" - description: - name: idb_shim - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - idb_sqflite: - dependency: "direct main" - description: - name: idb_sqflite - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" image_size_getter: dependency: "direct main" description: @@ -546,6 +651,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.4" + json_annotation: + dependency: transitive + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "4.6.0" kiwi: dependency: "direct main" description: @@ -833,6 +945,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" quiver: dependency: "direct main" description: @@ -840,6 +959,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.0" + recase: + dependency: transitive + description: + name: recase + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" rxdart: dependency: transitive description: @@ -882,13 +1008,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.0" - sembast: - dependency: transitive - description: - name: sembast - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.2" shared_preferences: dependency: "direct main" description: @@ -978,6 +1097,13 @@ packages: description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.2" source_map_stack_trace: dependency: transitive description: @@ -1000,7 +1126,7 @@ packages: source: hosted version: "1.9.0" sqflite: - dependency: "direct main" + dependency: transitive description: name: sqflite url: "https://pub.dartlang.org" @@ -1013,6 +1139,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.2.1+1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.2" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.8" + sqlparser: + dependency: transitive + description: + name: sqlparser + url: "https://pub.dartlang.org" + source: hosted + version: "0.22.0" stack_trace: dependency: transitive description: @@ -1076,6 +1223,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.16" + timing: + dependency: transitive + description: + name: timing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" tuple: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 020218aa..0efd41dc 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: git: url: https://gitlab.com/nc-photos/flutter-draggable-scrollbar ref: v0.1.0-nc-photos-5 + drift: ^1.7.1 equatable: ^2.0.0 event_bus: ^2.0.0 exifdart: @@ -65,9 +66,6 @@ dependencies: # android/ios only google_maps_flutter: ^2.1.0 http: ^0.13.1 - idb_shim: ^2.0.0 - # android/ios only - idb_sqflite: ^1.0.0 image_size_getter: git: url: https://gitlab.com/nc-photos/dart_image_size_getter @@ -88,8 +86,7 @@ dependencies: quiver: ^3.1.0 screen_brightness: ^0.2.1 shared_preferences: ^2.0.8 - # android/ios only - sqflite: ^2.0.0 + sqlite3_flutter_libs: ^0.5.8 synchronized: ^3.0.0 tuple: ^2.0.0 url_launcher: ^6.0.3 @@ -113,6 +110,8 @@ dependency_overrides: dev_dependencies: test: any bloc_test: any + build_runner: ^2.1.11 + drift_dev: ^1.7.0 flutter_lints: ^2.0.1 # flutter_test: # sdk: flutter diff --git a/app/test/bloc/list_album_share_outlier_test.dart b/app/test/bloc/list_album_share_outlier_test.dart index 1a5cf2be..97fc379b 100644 --- a/app/test/bloc/list_album_share_outlier_test.dart +++ b/app/test/bloc/list_album_share_outlier_test.dart @@ -2,7 +2,7 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:nc_photos/bloc/list_album_share_outlier.dart'; import 'package:nc_photos/ci_string.dart'; import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:test/test.dart'; import '../mock_type.dart'; @@ -56,8 +56,9 @@ void _initialState() { final c = DiContainer( shareRepo: MockShareRepo(), shareeRepo: MockShareeRepo(), - appDb: MockAppDb(), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); final bloc = ListAlbumShareOutlierBloc(c); expect(bloc.state.account, null); expect(bloc.state.items, const []); @@ -84,11 +85,14 @@ void _testQueryUnsharedAlbumExtraShare(String description) { shareeRepo: MockShareeMemoryRepo([ util.buildSharee(shareWith: "user1".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -112,18 +116,22 @@ void _testQueryUnsharedAlbumExtraJsonShare(String description) { final account = util.buildAccount(); final album = util.AlbumBuilder().build(); final albumFile = album.albumFile!; - final c = DiContainer( - shareRepo: MockShareMemoryRepo([ - util.buildShare(id: "0", file: albumFile, shareWith: "user1"), - ]), - shareeRepo: MockShareeMemoryRepo([ - util.buildSharee(shareWith: "user1".toCi()), - ]), - appDb: MockAppDb(), - ); + late final DiContainer c; blocTest( description, + setUp: () async { + c = DiContainer( + shareRepo: MockShareMemoryRepo([ + util.buildShare(id: "0", file: albumFile, shareWith: "user1"), + ]), + shareeRepo: MockShareeMemoryRepo([ + util.buildSharee(shareWith: "user1".toCi()), + ]), + sqliteDb: util.buildTestDb(), + ); + }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -164,11 +172,14 @@ void _testQuerySharedAlbumMissingShare(String description) { shareeRepo: MockShareeMemoryRepo([ util.buildSharee(shareWith: "user1".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -215,11 +226,14 @@ void _testQuerySharedAlbumMissingManagedShareOtherAdded(String description) { util.buildSharee(shareWith: "user1".toCi()), util.buildSharee(shareWith: "user2".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -272,12 +286,16 @@ void _testQuerySharedAlbumMissingManagedShareOtherReshared(String description) { util.buildSharee(shareWith: "user1".toCi()), util.buildSharee(shareWith: "user2".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDb(obj, user1Account, user1Files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertFiles(c.sqliteDb, user1Account, user1Files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -321,11 +339,14 @@ void _testQuerySharedAlbumMissingUnmanagedShareOtherAdded(String description) { util.buildSharee(shareWith: "user1".toCi()), util.buildSharee(shareWith: "user2".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -343,16 +364,20 @@ void _testQuerySharedAlbumMissingJsonShare(String description) { final account = util.buildAccount(); final album = (util.AlbumBuilder()..addShare("user1")).build(); final albumFile = album.albumFile!; - final c = DiContainer( - shareRepo: MockShareMemoryRepo(), - shareeRepo: MockShareeMemoryRepo([ - util.buildSharee(shareWith: "user1".toCi()), - ]), - appDb: MockAppDb(), - ); + late final DiContainer c; blocTest( description, + setUp: () async { + c = DiContainer( + shareRepo: MockShareMemoryRepo(), + shareeRepo: MockShareeMemoryRepo([ + util.buildSharee(shareWith: "user1".toCi()), + ]), + sqliteDb: util.buildTestDb(), + ); + }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -396,11 +421,14 @@ void _testQuerySharedAlbumExtraShare(String description) { util.buildSharee(shareWith: "user1".toCi()), util.buildSharee(shareWith: "user2".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -451,12 +479,16 @@ void _testQuerySharedAlbumExtraShareOtherAdded(String description) { util.buildSharee(shareWith: "user1".toCi()), util.buildSharee(shareWith: "user2".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDb(obj, user1Account, user1Files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertFiles(c.sqliteDb, user1Account, user1Files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -513,12 +545,16 @@ void _testQuerySharedAlbumExtraUnmanagedShare(String description) { util.buildSharee(shareWith: "user1".toCi()), util.buildSharee(shareWith: "user2".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDb(obj, user1Account, user1Files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertFiles(c.sqliteDb, user1Account, user1Files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -537,20 +573,24 @@ void _testQuerySharedAlbumExtraJsonShare(String description) { final account = util.buildAccount(); final album = (util.AlbumBuilder()..addShare("user1")).build(); final albumFile = album.albumFile!; - final c = DiContainer( - shareRepo: MockShareMemoryRepo([ - util.buildShare(id: "0", file: albumFile, shareWith: "user1"), - util.buildShare(id: "1", file: albumFile, shareWith: "user2"), - ]), - shareeRepo: MockShareeMemoryRepo([ - util.buildSharee(shareWith: "user1".toCi()), - util.buildSharee(shareWith: "user2".toCi()), - ]), - appDb: MockAppDb(), - ); + late final DiContainer c; blocTest( description, + setUp: () async { + c = DiContainer( + shareRepo: MockShareMemoryRepo([ + util.buildShare(id: "0", file: albumFile, shareWith: "user1"), + util.buildShare(id: "1", file: albumFile, shareWith: "user2"), + ]), + shareeRepo: MockShareeMemoryRepo([ + util.buildSharee(shareWith: "user1".toCi()), + util.buildSharee(shareWith: "user2".toCi()), + ]), + sqliteDb: util.buildTestDb(), + ); + }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -592,11 +632,14 @@ void _testQuerySharedAlbumNotOwnedMissingShareToOwner(String description) { shareeRepo: MockShareeMemoryRepo([ util.buildSharee(shareWith: "user1".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -643,11 +686,14 @@ void _testQuerySharedAlbumNotOwnedMissingManagedShare(String description) { util.buildSharee(shareWith: "user1".toCi()), util.buildSharee(shareWith: "user2".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -692,11 +738,14 @@ void _testQuerySharedAlbumNotOwnedMissingUnmanagedShare(String description) { util.buildSharee(shareWith: "user1".toCi()), util.buildSharee(shareWith: "user2".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -718,20 +767,24 @@ void _testQuerySharedAlbumNotOwnedMissingJsonShare(String description) { ..addShare("user2")) .build(); final albumFile = album.albumFile!; - final c = DiContainer( - shareRepo: MockShareMemoryRepo([ - util.buildShare( - id: "0", file: albumFile, uidOwner: "user1", shareWith: "admin"), - ]), - shareeRepo: MockShareeMemoryRepo([ - util.buildSharee(shareWith: "user1".toCi()), - util.buildSharee(shareWith: "user2".toCi()), - ]), - appDb: MockAppDb(), - ); + late final DiContainer c; blocTest( description, + setUp: () async { + c = DiContainer( + shareRepo: MockShareMemoryRepo([ + util.buildShare( + id: "0", file: albumFile, uidOwner: "user1", shareWith: "admin"), + ]), + shareeRepo: MockShareeMemoryRepo([ + util.buildSharee(shareWith: "user1".toCi()), + util.buildSharee(shareWith: "user2".toCi()), + ]), + sqliteDb: util.buildTestDb(), + ); + }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -772,11 +825,14 @@ void _testQuerySharedAlbumNotOwnedExtraManagedShare(String description) { util.buildSharee(shareWith: "user1".toCi()), util.buildSharee(shareWith: "user2".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -822,11 +878,14 @@ void _testQuerySharedAlbumNotOwnedExtraUnmanagedShare(String description) { util.buildSharee(shareWith: "user1".toCi()), util.buildSharee(shareWith: "user2".toCi()), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), @@ -851,22 +910,26 @@ void _testQuerySharedAlbumNotOwnedExtraJsonShare(String description) { final album = (util.AlbumBuilder(ownerId: "user1")..addShare("admin")).build(); final albumFile = album.albumFile!; - final c = DiContainer( - shareRepo: MockShareMemoryRepo([ - util.buildShare( - id: "0", file: albumFile, uidOwner: "user1", shareWith: "admin"), - util.buildShare( - id: "1", file: albumFile, uidOwner: "user1", shareWith: "user2"), - ]), - shareeRepo: MockShareeMemoryRepo([ - util.buildSharee(shareWith: "user1".toCi()), - util.buildSharee(shareWith: "user2".toCi()), - ]), - appDb: MockAppDb(), - ); + late final DiContainer c; blocTest( description, + setUp: () async { + c = DiContainer( + shareRepo: MockShareMemoryRepo([ + util.buildShare( + id: "0", file: albumFile, uidOwner: "user1", shareWith: "admin"), + util.buildShare( + id: "1", file: albumFile, uidOwner: "user1", shareWith: "user2"), + ]), + shareeRepo: MockShareeMemoryRepo([ + util.buildSharee(shareWith: "user1".toCi()), + util.buildSharee(shareWith: "user2".toCi()), + ]), + sqliteDb: util.buildTestDb(), + ); + }, + tearDown: () => c.sqliteDb.close(), build: () => ListAlbumShareOutlierBloc(c), act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)), wait: const Duration(milliseconds: 500), diff --git a/app/test/bloc/ls_dir_test.dart b/app/test/bloc/ls_dir_test.dart index 1134c16e..d303826a 100644 --- a/app/test/bloc/ls_dir_test.dart +++ b/app/test/bloc/ls_dir_test.dart @@ -1,8 +1,6 @@ import 'package:bloc_test/bloc_test.dart'; -import 'package:nc_photos/account.dart'; import 'package:nc_photos/bloc/ls_dir.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:path/path.dart' as path_lib; import 'package:test/test.dart'; import '../mock_type.dart'; @@ -134,32 +132,30 @@ void main() { }); } -class _MockFileRepo extends MockFileRepo { - @override - list(Account account, File root) async { - return [ - File( - path: "remote.php/dav/files/admin/test1.jpg", - ), - File( - path: "remote.php/dav/files/admin/d1", - isCollection: true, - ), - File( - path: "remote.php/dav/files/admin/d1/test2.jpg", - ), - File( - path: "remote.php/dav/files/admin/d1/d2-1", - isCollection: true, - ), - File( - path: "remote.php/dav/files/admin/d1/d2-2", - isCollection: true, - ), - File( - path: "remote.php/dav/files/admin/d1/d2-1/d3", - isCollection: true, - ), - ].where((element) => path_lib.dirname(element.path) == root.path).toList(); - } +class _MockFileRepo extends MockFileMemoryRepo { + _MockFileRepo() + : super([ + File( + path: "remote.php/dav/files/admin/test1.jpg", + ), + File( + path: "remote.php/dav/files/admin/d1", + isCollection: true, + ), + File( + path: "remote.php/dav/files/admin/d1/test2.jpg", + ), + File( + path: "remote.php/dav/files/admin/d1/d2-1", + isCollection: true, + ), + File( + path: "remote.php/dav/files/admin/d1/d2-2", + isCollection: true, + ), + File( + path: "remote.php/dav/files/admin/d1/d2-1/d3", + isCollection: true, + ), + ]); } diff --git a/app/test/entity/album/data_source_test.dart b/app/test/entity/album/data_source_test.dart new file mode 100644 index 00000000..69079e04 --- /dev/null +++ b/app/test/entity/album/data_source_test.dart @@ -0,0 +1,335 @@ +import 'package:nc_photos/ci_string.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/cover_provider.dart'; +import 'package:nc_photos/entity/album/data_source.dart'; +import 'package:nc_photos/entity/album/item.dart'; +import 'package:nc_photos/entity/album/provider.dart'; +import 'package:nc_photos/entity/album/sort_provider.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; +import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/or_null.dart'; +import 'package:test/test.dart'; + +import '../../test_util.dart' as util; + +void main() { + group("AlbumSqliteDbDataSource", () { + group("get", () { + test("normal", _dbGet); + test("n/a", _dbGetNa); + }); + group("getAll", () { + test("normal", _dbGetAll); + test("n/a", _dbGetAllNa); + }); + group("update", () { + test("existing album", _dbUpdateExisting); + test("new album", _dbUpdateNew); + test("shares", _dbUpdateShares); + test("delete shares", _dbUpdateDeleteShares); + }); + }); +} + +/// Get an album from DB +/// +/// Expect: album +Future _dbGet() async { + final account = util.buildAccount(); + final albums = [ + (util.AlbumBuilder.ofId(albumId: 0)).build(), + (util.AlbumBuilder.ofId(albumId: 1)).build(), + ]; + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles( + c.sqliteDb, account, albums.map((a) => a.albumFile!)); + await util.insertAlbums(c.sqliteDb, account, albums); + }); + + final src = AlbumSqliteDbDataSource(c); + expect(await src.get(account, albums[0].albumFile!), albums[0]); +} + +/// Get an album that doesn't exist in DB +/// +/// Expect: CacheNotFoundException +Future _dbGetNa() async { + final account = util.buildAccount(); + final albums = [ + (util.AlbumBuilder.ofId(albumId: 0)).build(), + ]; + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + }); + + final src = AlbumSqliteDbDataSource(c); + expect( + () async => await src.get(account, albums[0].albumFile!), + throwsA(const TypeMatcher()), + ); +} + +/// Get multiple albums from DB +/// +/// Expect: albums +Future _dbGetAll() async { + final account = util.buildAccount(); + final albums = [ + (util.AlbumBuilder.ofId(albumId: 0)).build(), + (util.AlbumBuilder.ofId(albumId: 1)).build(), + (util.AlbumBuilder.ofId(albumId: 2)).build(), + ]; + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles( + c.sqliteDb, account, albums.map((a) => a.albumFile!)); + await util.insertAlbums(c.sqliteDb, account, albums); + }); + + final src = AlbumSqliteDbDataSource(c); + expect( + await src + .getAll(account, [albums[0].albumFile!, albums[2].albumFile!]).toList(), + [albums[0], albums[2]], + ); +} + +/// Get multiple albums that doesn't exists in DB +/// +/// Expect: ExceptionEvent with CacheNotFoundException +Future _dbGetAllNa() async { + final account = util.buildAccount(); + final albums = [ + (util.AlbumBuilder.ofId(albumId: 0)).build(), + (util.AlbumBuilder.ofId(albumId: 1)).build(), + (util.AlbumBuilder.ofId(albumId: 2)).build(), + ]; + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, [albums[0].albumFile!]); + await util.insertAlbums(c.sqliteDb, account, [albums[0]]); + }); + + final src = AlbumSqliteDbDataSource(c); + final results = await src + .getAll(account, [albums[0].albumFile!, albums[2].albumFile!]).toList(); + expect(results.length, 2); + expect(results[0], albums[0]); + expect( + () => throw results[1], + throwsA(const TypeMatcher()), + ); +} + +/// Update an existing album in DB +/// +/// Expect: album updated +Future _dbUpdateExisting() async { + final account = util.buildAccount(); + final albums = [ + (util.AlbumBuilder.ofId(albumId: 0)).build(), + (util.AlbumBuilder.ofId(albumId: 1)).build(), + ]; + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg")) + .build(); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles( + c.sqliteDb, account, albums.map((a) => a.albumFile!)); + await util.insertAlbums(c.sqliteDb, account, albums); + }); + + final updateAlbum = albums[1].copyWith( + name: "edit", + lastUpdated: OrNull(DateTime.utc(2021, 2, 3, 4, 5, 6)), + provider: AlbumStaticProvider( + items: [ + AlbumLabelItem( + addedBy: "admin".toCi(), + addedAt: DateTime.utc(2021, 2, 3, 4, 5, 6), + text: "test", + ), + AlbumFileItem( + addedBy: "admin".toCi(), + addedAt: DateTime.utc(2021, 2, 3, 4, 5, 6), + file: files[1], + ), + ], + ), + coverProvider: AlbumManualCoverProvider(coverFile: files[1]), + sortProvider: const AlbumTimeSortProvider(isAscending: true), + ); + final src = AlbumSqliteDbDataSource(c); + await src.update(account, updateAlbum); + expect( + await util.listSqliteDbAlbums(c.sqliteDb), + {albums[0], updateAlbum}, + ); +} + +/// Update an album that doesn't exist in DB +/// +/// Expect: album inserted +Future _dbUpdateNew() async { + final account = util.buildAccount(); + final albums = [ + (util.AlbumBuilder.ofId(albumId: 0)).build(), + ]; + final newAlbum = (util.AlbumBuilder.ofId(albumId: 1)).build(); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, + [...albums.map((a) => a.albumFile!), newAlbum.albumFile!]); + await util.insertAlbums(c.sqliteDb, account, albums); + }); + + final src = AlbumSqliteDbDataSource(c); + await src.update(account, newAlbum); + expect( + await util.listSqliteDbAlbums(c.sqliteDb), + {...albums, newAlbum}, + ); +} + +/// Update shares of an album +/// +/// Expect: album shares updated +Future _dbUpdateShares() async { + final account = util.buildAccount(); + final albums = [ + (util.AlbumBuilder.ofId(albumId: 0)).build(), + (util.AlbumBuilder.ofId(albumId: 1)..addShare("user1")).build(), + ]; + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg")) + .build(); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles( + c.sqliteDb, account, albums.map((a) => a.albumFile!)); + await util.insertAlbums(c.sqliteDb, account, albums); + }); + + final updateAlbum = albums[1].copyWith( + name: "edit", + lastUpdated: OrNull(DateTime.utc(2021, 2, 3, 4, 5, 6)), + provider: AlbumStaticProvider( + items: [ + AlbumLabelItem( + addedBy: "admin".toCi(), + addedAt: DateTime.utc(2021, 2, 3, 4, 5, 6), + text: "test", + ), + AlbumFileItem( + addedBy: "admin".toCi(), + addedAt: DateTime.utc(2021, 2, 3, 4, 5, 6), + file: files[1], + ), + ], + ), + coverProvider: AlbumManualCoverProvider(coverFile: files[1]), + sortProvider: const AlbumTimeSortProvider(isAscending: true), + shares: OrNull([ + AlbumShare( + userId: "user1".toCi(), + sharedAt: DateTime.utc(2021, 2, 3, 4, 5, 6), + ), + AlbumShare( + userId: "user2".toCi(), + sharedAt: DateTime.utc(2021, 2, 3, 4, 5, 7), + ), + ]), + ); + final src = AlbumSqliteDbDataSource(c); + await src.update(account, updateAlbum); + expect( + await util.listSqliteDbAlbums(c.sqliteDb), + {albums[0], updateAlbum}, + ); +} + +/// Delete shares of an album +/// +/// Expect: album shares deleted +Future _dbUpdateDeleteShares() async { + final account = util.buildAccount(); + final albums = [ + (util.AlbumBuilder.ofId(albumId: 0)).build(), + (util.AlbumBuilder.ofId(albumId: 1)..addShare("user1")).build(), + ]; + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg")) + .build(); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles( + c.sqliteDb, account, albums.map((a) => a.albumFile!)); + await util.insertAlbums(c.sqliteDb, account, albums); + }); + + final updateAlbum = albums[1].copyWith( + name: "edit", + lastUpdated: OrNull(DateTime.utc(2021, 2, 3, 4, 5, 6)), + provider: AlbumStaticProvider( + items: [ + AlbumLabelItem( + addedBy: "admin".toCi(), + addedAt: DateTime.utc(2021, 2, 3, 4, 5, 6), + text: "test", + ), + AlbumFileItem( + addedBy: "admin".toCi(), + addedAt: DateTime.utc(2021, 2, 3, 4, 5, 6), + file: files[1], + ), + ], + ), + coverProvider: AlbumManualCoverProvider(coverFile: files[1]), + sortProvider: const AlbumTimeSortProvider(isAscending: true), + shares: OrNull(null), + ); + final src = AlbumSqliteDbDataSource(c); + await src.update(account, updateAlbum); + expect( + await util.listSqliteDbAlbums(c.sqliteDb), + {albums[0], updateAlbum}, + ); +} diff --git a/app/test/entity/file/data_source_test.dart b/app/test/entity/file/data_source_test.dart index 5f455828..8023e89a 100644 --- a/app/test/entity/file/data_source_test.dart +++ b/app/test/entity/file/data_source_test.dart @@ -1,16 +1,15 @@ -import 'package:nc_photos/app_db.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/list_extension.dart'; -import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/or_null.dart'; import 'package:test/test.dart'; -import '../../mock_type.dart'; import '../../test_util.dart' as util; void main() { - group("FileAppDbDataSource", () { + group("FileSqliteDbDataSource", () { test("list", _list); test("listSingle", _listSingle); group("remove", () { @@ -19,7 +18,12 @@ void main() { test("dir w/ file", _removeDir); test("dir w/ sub dir", _removeDirWithSubDir); }); - test("updateProperty", _updateProperty); + group("updateProperty", () { + test("file properties", _updateFileProperty); + test("update metadata", _updateMetadata); + test("add metadata", _updateAddMetadata); + test("delete metadata", _updateDeleteMetadata); + }); }); } @@ -38,12 +42,19 @@ Future _list() async { ..addDir("admin/test") ..addJpeg("admin/test/test2.jpg")) .build(); - final appDb = await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDbDir(obj, account, files[0], files.slice(1, 3)); - await util.fillAppDbDir(obj, account, files[2], [files[3]]); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation( + c.sqliteDb, account, files[0], files.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); }); - final src = FileAppDbDataSource(appDb); + + final src = FileSqliteDbDataSource(c); expect(await src.list(account, files[0]), files.slice(0, 3)); expect(await src.list(account, files[2]), files.slice(2, 4)); } @@ -54,69 +65,83 @@ Future _list() async { Future _listSingle() async { final account = util.buildAccount(); final files = (util.FilesBuilder()..addDir("admin")).build(); - final appDb = await MockAppDb().applyFuture((obj) async { - await util.fillAppDbDir(obj, account, files[0], []); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation(c.sqliteDb, account, files[0], const []); }); - final src = FileAppDbDataSource(appDb); + + final src = FileSqliteDbDataSource(c); expect(() async => await src.listSingle(account, files[0]), throwsUnimplementedError); } /// Remove a file /// -/// Expect: entry removed from file2Store +/// Expect: entry removed from Files table Future _removeFile() async { final account = util.buildAccount(); final files = (util.FilesBuilder() ..addDir("admin") ..addJpeg("admin/test1.jpg")) .build(); - final appDb = await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDbDir(obj, account, files[0], [files[1]]); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]); }); - final src = FileAppDbDataSource(appDb); + + final src = FileSqliteDbDataSource(c); await src.remove(account, files[1]); expect( - (await util.listAppDb( - appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e))) - .map((e) => e.file) - .toList(), - [files[0]], + await util.listSqliteDbFiles(c.sqliteDb), + {files[0]}, ); } /// Remove an empty dir /// -/// Expect: dir entry removed from dirStore; -/// no changes to parent dir entry +/// Expect: entry removed from DirFiles table Future _removeEmptyDir() async { final account = util.buildAccount(); final files = (util.FilesBuilder() ..addDir("admin") ..addDir("admin/test")) .build(); - final appDb = await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDbDir(obj, account, files[0], [files[1]]); - await util.fillAppDbDir(obj, account, files[1], []); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]); + await util.insertDirRelation(c.sqliteDb, account, files[1], const []); }); - final src = FileAppDbDataSource(appDb); + + final src = FileSqliteDbDataSource(c); await src.remove(account, files[1]); // parent dir is not updated, parent dir is only updated when syncing with // remote expect( - await util.listAppDb( - appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e)), - [ - AppDbDirEntry.fromFiles(account, files[0], [files[1]]), - ], + await util.listSqliteDbDirs(c.sqliteDb), + { + files[0]: {files[0]}, + }, ); } /// Remove a dir with file /// -/// Expect: file entries under the dir removed from file2Store +/// Expect: file entries under the dir removed from Files table Future _removeDir() async { final account = util.buildAccount(); final files = (util.FilesBuilder() @@ -124,25 +149,28 @@ Future _removeDir() async { ..addDir("admin/test") ..addJpeg("admin/test/test1.jpg")) .build(); - final appDb = await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDbDir(obj, account, files[0], [files[1]]); - await util.fillAppDbDir(obj, account, files[1], [files[2]]); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]); + await util.insertDirRelation(c.sqliteDb, account, files[1], [files[2]]); }); - final src = FileAppDbDataSource(appDb); + + final src = FileSqliteDbDataSource(c); await src.remove(account, files[1]); expect( - (await util.listAppDb( - appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e))) - .map((e) => e.file) - .toList(), - [files[0]], + await util.listSqliteDbFiles(c.sqliteDb), + {files[0]}, ); } /// Remove a dir with file /// -/// Expect: file entries under the dir removed from file2Store +/// Expect: file entries under the dir removed from Files table Future _removeDirWithSubDir() async { final account = util.buildAccount(); final files = (util.FilesBuilder() @@ -151,75 +179,192 @@ Future _removeDirWithSubDir() async { ..addDir("admin/test/test2") ..addJpeg("admin/test/test2/test3.jpg")) .build(); - final appDb = await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDbDir(obj, account, files[0], [files[1]]); - await util.fillAppDbDir(obj, account, files[1], [files[2]]); - await util.fillAppDbDir(obj, account, files[2], [files[3]]); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]); + await util.insertDirRelation(c.sqliteDb, account, files[1], [files[2]]); + await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); }); - final src = FileAppDbDataSource(appDb); + + final src = FileSqliteDbDataSource(c); await src.remove(account, files[1]); expect( - await util.listAppDb( - appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e)), - [ - AppDbDirEntry.fromFiles(account, files[0], [files[1]]), - ], + await util.listSqliteDbDirs(c.sqliteDb), + { + files[0]: {files[0]} + }, ); expect( - (await util.listAppDb( - appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e))) - .map((e) => e.file) - .toList(), - [files[0]], + await util.listSqliteDbFiles(c.sqliteDb), + {files[0]}, ); } /// Update the properties of a file /// -/// Expect: file's property updated in file2Store; -/// file's property updated in dirStore -Future _updateProperty() async { +/// Expect: file's property updated in Files table +Future _updateFileProperty() async { final account = util.buildAccount(); final files = (util.FilesBuilder() ..addDir("admin") ..addJpeg("admin/test1.jpg")) .build(); - final appDb = await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDbDir(obj, account, files[0], [files[1]]); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]); }); - final src = FileAppDbDataSource(appDb); + + final src = FileSqliteDbDataSource(c); + await src.updateProperty( + account, + files[1], + isArchived: OrNull(true), + overrideDateTime: OrNull(DateTime.utc(2020, 1, 2, 3, 4, 5)), + ); + final expectFile = files[1].copyWith( + isArchived: OrNull(true), + overrideDateTime: OrNull(DateTime.utc(2020, 1, 2, 3, 4, 5)), + ); + expect( + await util.listSqliteDbFiles(c.sqliteDb), + {files[0], expectFile}, + ); +} + +/// Update metadata of a file +/// +/// Expect: Metadata updated in Images table +Future _updateMetadata() async { + final account = util.buildAccount(); + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg")) + .build(); + files[1] = files[1].copyWith( + metadata: OrNull(Metadata( + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + imageWidth: 123, + )), + ); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]); + }); + + final src = FileSqliteDbDataSource(c); await src.updateProperty( account, files[1], metadata: OrNull(Metadata( - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678), - imageWidth: 123, + lastUpdated: DateTime.utc(2021, 1, 2, 3, 4, 5), + imageWidth: 321, + imageHeight: 123, )), - isArchived: OrNull(true), - overrideDateTime: OrNull(DateTime.utc(2020, 1, 2, 3, 4, 5, 678)), ); final expectFile = files[1].copyWith( metadata: OrNull(Metadata( - lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678), - imageWidth: 123, + lastUpdated: DateTime.utc(2021, 1, 2, 3, 4, 5), + imageWidth: 321, + imageHeight: 123, )), - isArchived: OrNull(true), - overrideDateTime: OrNull(DateTime.utc(2020, 1, 2, 3, 4, 5, 678)), ); expect( - await util.listAppDb( - appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e)), - [ - AppDbDirEntry.fromFiles(account, files[0], [expectFile]), - ], - ); - expect( - (await util.listAppDb( - appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e))) - .map((e) => e.file) - .toList(), - [files[0], expectFile], + await util.listSqliteDbFiles(c.sqliteDb), + {files[0], expectFile}, + ); +} + +/// Add metadata to a file +/// +/// Expect: Metadata added to Images table +Future _updateAddMetadata() async { + final account = util.buildAccount(); + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg")) + .build(); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]); + }); + + final src = FileSqliteDbDataSource(c); + await src.updateProperty( + account, + files[1], + metadata: OrNull(Metadata( + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + imageWidth: 123, + )), + ); + final expectFile = files[1].copyWith( + metadata: OrNull(Metadata( + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + imageWidth: 123, + )), + ); + expect( + await util.listSqliteDbFiles(c.sqliteDb), + {files[0], expectFile}, + ); +} + +/// Delete metadata of a file +/// +/// Expect: Metadata deleted from Images table +Future _updateDeleteMetadata() async { + final account = util.buildAccount(); + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg")) + .build(); + files[1] = files[1].copyWith( + metadata: OrNull(Metadata( + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + imageWidth: 123, + )), + ); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]); + }); + + final src = FileSqliteDbDataSource(c); + await src.updateProperty( + account, + files[1], + metadata: OrNull(null), + ); + final expectFile = files[1].copyWith( + metadata: OrNull(null), + ); + expect( + await util.listSqliteDbFiles(c.sqliteDb), + {files[0], expectFile}, ); } diff --git a/app/test/entity/file/file_cache_manager_test.dart b/app/test/entity/file/file_cache_manager_test.dart new file mode 100644 index 00000000..61c6d000 --- /dev/null +++ b/app/test/entity/file/file_cache_manager_test.dart @@ -0,0 +1,502 @@ +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/file/file_cache_manager.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; +import 'package:nc_photos/list_extension.dart'; +import 'package:nc_photos/or_null.dart'; +import 'package:test/test.dart'; + +import '../../mock_type.dart'; +import '../../test_util.dart' as util; + +void main() { + group("FileCacheLoader", () { + group("default", () { + test("no cache", _loaderNoCache); + test("outdated", _loaderOutdatedCache); + test("query remote etag", _loaderQueryRemoteSameEtag); + test("query remote etag (updated)", _loaderQueryRemoteDiffEtag); + }); + }); + group("FileSqliteCacheUpdater", () { + test("identical", _updaterIdentical); + test("new file", _updaterNewFile); + test("delete file", _updaterDeleteFile); + test("delete dir", _updaterDeleteDir); + test("update file", _updaterUpdateFile); + test("new shared file", _updaterNewSharedFile); + test("new shared dir", _updaterNewSharedDir); + test("delete shared file", _updaterDeleteSharedFile); + test("delete shared dir", _updaterDeleteSharedDir); + }); +} + +/// Load dir: no cache +/// +/// Expect: null +Future _loaderNoCache() async { + final account = util.buildAccount(); + final files = (util.FilesBuilder() + ..addDir("admin", etag: "1") + ..addJpeg("admin/test1.jpg", etag: "2") + ..addDir("admin/test", etag: "3") + ..addJpeg("admin/test/test2.jpg", etag: "4")) + .build(); + final c = DiContainer( + fileRepo: MockFileMemoryRepo(files), + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + }); + + final cacheSrc = FileSqliteDbDataSource(c); + final remoteSrc = MockFileWebdavDataSource(MockFileMemoryDataSource(files)); + final loader = FileCacheLoader(c, cacheSrc: cacheSrc, remoteSrc: remoteSrc); + expect(await loader(account, files[0]), null); +} + +/// Load dir: outdated cache +/// +/// Expect: return cache; +/// isGood == false +Future _loaderOutdatedCache() async { + final account = util.buildAccount(); + final files = (util.FilesBuilder() + ..addDir("admin", etag: "1") + ..addJpeg("admin/test1.jpg", etag: "2") + ..addDir("admin/test", etag: "3") + ..addJpeg("admin/test/test2.jpg", etag: "4")) + .build(); + final c = DiContainer( + fileRepo: MockFileMemoryRepo(files), + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + final dbFiles = [ + files[0].copyWith(etag: OrNull("a")), + ...files.slice(1), + ]; + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, dbFiles); + await util.insertDirRelation( + c.sqliteDb, account, dbFiles[0], dbFiles.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, dbFiles[2], [dbFiles[3]]); + }); + + final cacheSrc = FileSqliteDbDataSource(c); + final remoteSrc = MockFileWebdavDataSource(MockFileMemoryDataSource(files)); + final loader = FileCacheLoader(c, cacheSrc: cacheSrc, remoteSrc: remoteSrc); + expect( + (await loader(account, files[0]))?.toSet(), + dbFiles.slice(0, 3).toSet(), + ); + expect(loader.isGood, false); +} + +/// Load dir: no etag, up-to-date cache +/// +/// Expect: return cache; +/// isGood == true +Future _loaderQueryRemoteSameEtag() async { + final account = util.buildAccount(); + final files = (util.FilesBuilder() + ..addDir("admin", etag: "1") + ..addJpeg("admin/test1.jpg", etag: "2") + ..addDir("admin/test", etag: "3") + ..addJpeg("admin/test/test2.jpg", etag: "4")) + .build(); + final c = DiContainer( + fileRepo: MockFileMemoryRepo(files), + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation( + c.sqliteDb, account, files[0], files.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); + }); + + final cacheSrc = FileSqliteDbDataSource(c); + final remoteSrc = MockFileWebdavDataSource(MockFileMemoryDataSource(files)); + final loader = FileCacheLoader(c, cacheSrc: cacheSrc, remoteSrc: remoteSrc); + expect( + (await loader(account, files[0].copyWith(etag: OrNull(null))))?.toSet(), + files.slice(0, 3).toSet(), + ); + expect(loader.isGood, true); +} + +/// Load dir: no etag, outdated cache +/// +/// Expect: return cache; +/// isGood == false +Future _loaderQueryRemoteDiffEtag() async { + final account = util.buildAccount(); + final files = (util.FilesBuilder() + ..addDir("admin", etag: "1") + ..addJpeg("admin/test1.jpg", etag: "2") + ..addDir("admin/test", etag: "3") + ..addJpeg("admin/test/test2.jpg", etag: "4")) + .build(); + final c = DiContainer( + fileRepo: MockFileMemoryRepo(files), + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + final dbFiles = [ + files[0].copyWith(etag: OrNull("a")), + ...files.slice(1), + ]; + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, dbFiles); + await util.insertDirRelation( + c.sqliteDb, account, dbFiles[0], dbFiles.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, dbFiles[2], [dbFiles[3]]); + }); + + final cacheSrc = FileSqliteDbDataSource(c); + final remoteSrc = MockFileWebdavDataSource(MockFileMemoryDataSource(files)); + final loader = FileCacheLoader(c, cacheSrc: cacheSrc, remoteSrc: remoteSrc); + expect( + (await loader(account, files[0].copyWith(etag: OrNull(null))))?.toSet(), + dbFiles.slice(0, 3).toSet(), + ); + expect(loader.isGood, false); +} + +/// Update dir in cache: same set of files +/// +/// Expect: nothing happens +Future _updaterIdentical() async { + final account = util.buildAccount(); + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg") + ..addDir("admin/test") + ..addJpeg("admin/test/test2.jpg")) + .build(); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation( + c.sqliteDb, account, files[0], files.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); + }); + + final updater = FileSqliteCacheUpdater(c); + await updater(account, files[0], remote: files.slice(0, 3)); + expect( + await util.listSqliteDbFiles(c.sqliteDb), + files.toSet(), + ); +} + +/// Update dir in cache: new file +/// +/// Expect: new file added to Files table +Future _updaterNewFile() async { + final account = util.buildAccount(); + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg") + ..addDir("admin/test") + ..addJpeg("admin/test/test2.jpg")) + .build(); + final newFile = (util.FilesBuilder(initialFileId: files.length) + ..addJpeg("admin/test2.jpg")) + .build() + .first; + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation( + c.sqliteDb, account, files[0], files.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); + }); + + final updater = FileSqliteCacheUpdater(c); + await updater(account, files[0], remote: [...files.slice(0, 3), newFile]); + expect( + await util.listSqliteDbFiles(c.sqliteDb), + {...files, newFile}, + ); +} + +/// Update dir in cache: file missing +/// +/// Expect: missing file deleted from Files table +Future _updaterDeleteFile() async { + final account = util.buildAccount(); + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg") + ..addDir("admin/test") + ..addJpeg("admin/test/test2.jpg")) + .build(); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation( + c.sqliteDb, account, files[0], files.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); + }); + + final updater = FileSqliteCacheUpdater(c); + await updater(account, files[0], remote: [files[0], files[2]]); + expect( + await util.listSqliteDbFiles(c.sqliteDb), + {files[0], ...files.slice(2)}, + ); +} + +/// Update dir in cache: dir missing +/// +/// Expect: missing dir deleted from Files table; +/// missing dir deleted from DirFiles table +/// files under dir deleted from Files table; +/// dirs under dir deleted from DirFiles table; +Future _updaterDeleteDir() async { + final account = util.buildAccount(); + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg") + ..addDir("admin/test") + ..addJpeg("admin/test/test2.jpg")) + .build(); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation( + c.sqliteDb, account, files[0], files.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); + }); + + final updater = FileSqliteCacheUpdater(c); + await updater(account, files[0], remote: files.slice(0, 2)); + expect( + await util.listSqliteDbFiles(c.sqliteDb), + files.slice(0, 2).toSet(), + ); + expect( + await util.listSqliteDbDirs(c.sqliteDb), + { + files[0]: files.slice(0, 2).toSet(), + }, + ); +} + +/// Update dir in cache: file updated +/// +/// Expect: file updated in Files table +Future _updaterUpdateFile() async { + final account = util.buildAccount(); + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg", contentLength: 321) + ..addDir("admin/test") + ..addJpeg("admin/test/test2.jpg")) + .build(); + final newFile = files[1].copyWith(contentLength: 654); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation( + c.sqliteDb, account, files[0], files.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); + }); + + final updater = FileSqliteCacheUpdater(c); + await updater(account, files[0], + remote: [files[0], newFile, ...files.slice(2)]); + expect( + await util.listSqliteDbFiles(c.sqliteDb), + {files[0], newFile, ...files.slice(2)}, + ); +} + +/// Update dir in cache: new shared file +/// +/// Expect: file added to AccountFiles table +Future _updaterNewSharedFile() async { + final account = util.buildAccount(); + final user1Account = util.buildAccount(username: "user1"); + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg") + ..addDir("admin/test") + ..addJpeg("admin/test/test2.jpg")) + .build(); + final user1Files = (util.FilesBuilder(initialFileId: files.length) + ..addDir("user1", ownerId: "user1")) + .build(); + user1Files + .add(files[1].copyWith(path: "remote.php/dav/files/user1/test1.jpg")); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation( + c.sqliteDb, account, files[0], files.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); + }); + + final updater = FileSqliteCacheUpdater(c); + await updater(user1Account, user1Files[0], remote: user1Files); + expect( + await util.listSqliteDbFiles(c.sqliteDb), + {...files, ...user1Files}, + ); +} + +/// Update dir in cache: new shared dir +/// +/// Expect: file added to AccountFiles table +Future _updaterNewSharedDir() async { + final account = util.buildAccount(); + final user1Account = util.buildAccount(username: "user1"); + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg", ownerId: "user1") + ..addDir("admin/test") + ..addJpeg("admin/test/test2.jpg")) + .build(); + final user1Files = []; + user1Files.add(files[2].copyWith(path: "remote.php/dav/files/user1/share")); + user1Files.add( + files[3].copyWith(path: "remote.php/dav/files/user1/share/test2.jpg")); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation( + c.sqliteDb, account, files[0], files.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); + }); + + final updater = FileSqliteCacheUpdater(c); + await updater(user1Account, user1Files[0], remote: user1Files); + expect( + await util.listSqliteDbFiles(c.sqliteDb), + {...files, ...user1Files}, + ); +} + +/// Update dir in cache: shared file missing +/// +/// Expect: file removed from AccountFiles table; +/// file remained in Files table +Future _updaterDeleteSharedFile() async { + final account = util.buildAccount(); + final user1Account = util.buildAccount(username: "user1"); + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg") + ..addDir("admin/test") + ..addJpeg("admin/test/test2.jpg")) + .build(); + final user1Files = + (util.FilesBuilder(initialFileId: files.length)..addDir("user1")).build(); + user1Files + .add(files[1].copyWith(path: "remote.php/dav/files/user1/test1.jpg")); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation( + c.sqliteDb, account, files[0], files.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); + + await util.insertFiles(c.sqliteDb, user1Account, user1Files); + await util.insertDirRelation( + c.sqliteDb, user1Account, user1Files[0], [user1Files[1]]); + }); + + final updater = FileSqliteCacheUpdater(c); + await updater(user1Account, user1Files[0], remote: [user1Files[0]]); + expect( + await util.listSqliteDbFiles(c.sqliteDb), + {...files, user1Files[0]}, + ); +} + +/// Update dir in cache: shared dir missing +/// +/// Expect: file removed from AccountFiles table; +/// file remained in Files table +Future _updaterDeleteSharedDir() async { + final account = util.buildAccount(); + final user1Account = util.buildAccount(username: "user1"); + final files = (util.FilesBuilder() + ..addDir("admin") + ..addJpeg("admin/test1.jpg") + ..addDir("admin/test") + ..addJpeg("admin/test/test2.jpg")) + .build(); + final user1Files = + (util.FilesBuilder(initialFileId: files.length)..addDir("user1")).build(); + user1Files.add(files[2].copyWith(path: "remote.php/dav/files/user1/share")); + user1Files.add( + files[3].copyWith(path: "remote.php/dav/files/user1/share/test2.jpg")); + final c = DiContainer( + sqliteDb: util.buildTestDb(), + ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertDirRelation( + c.sqliteDb, account, files[0], files.slice(1, 3)); + await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]); + + await util.insertFiles(c.sqliteDb, user1Account, user1Files); + await util.insertDirRelation( + c.sqliteDb, user1Account, user1Files[0], [user1Files[1]]); + }); + + final updater = FileSqliteCacheUpdater(c); + await updater(user1Account, user1Files[0], remote: [user1Files[0]]); + expect( + await util.listSqliteDbFiles(c.sqliteDb), + {...files, user1Files[0]}, + ); +} diff --git a/app/test/entity/file_test.dart b/app/test/entity/file_test.dart index a1470b53..d5c0d4e3 100644 --- a/app/test/entity/file_test.dart +++ b/app/test/entity/file_test.dart @@ -752,7 +752,7 @@ void main() { }); test("etag", () { - final file = src.copyWith(etag: "000"); + final file = src.copyWith(etag: OrNull("000")); expect( file, File( diff --git a/app/test/mock_type.dart b/app/test/mock_type.dart index cec8bfe0..9680383e 100644 --- a/app/test/mock_type.dart +++ b/app/test/mock_type.dart @@ -3,26 +3,22 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:event_bus/event_bus.dart'; -import 'package:idb_shim/idb.dart'; -import 'package:idb_shim/idb_client_memory.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/ci_string.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/sharee.dart'; +import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/future_util.dart' as future_util; import 'package:nc_photos/or_null.dart'; +import 'package:path/path.dart' as path_lib; /// Mock of [AlbumRepo] where all methods will throw UnimplementedError class MockAlbumRepo implements AlbumRepo { - @override - Future cleanUp(Account account, String rootDir, List albumFiles) { - throw UnimplementedError(); - } - @override Future create(Account account, Album album) { throw UnimplementedError(); @@ -36,6 +32,11 @@ class MockAlbumRepo implements AlbumRepo { throw UnimplementedError(); } + @override + Stream getAll(Account account, List albumFiles) { + throw UnimplementedError(); + } + @override Future update(Account account, Album album) { throw UnimplementedError(); @@ -54,6 +55,17 @@ class MockAlbumMemoryRepo extends MockAlbumRepo { element.albumFile?.compareServerIdentity(albumFile) == true); } + @override + getAll(Account account, List albumFiles) async* { + final results = await future_util.waitOr( + albumFiles.map((f) => get(account, f)), + (error, stackTrace) => ExceptionEvent(error, stackTrace), + ); + for (final r in results) { + yield r; + } + } + @override update(Account account, Album album) async { final i = albums.indexWhere((element) => @@ -67,120 +79,6 @@ class MockAlbumMemoryRepo extends MockAlbumRepo { final List albums; } -/// Each MockAppDb instance contains a unique memory database -class MockAppDb implements AppDb { - static Future create({ - bool hasAlbumStore = true, - bool hasFileDb2Store = true, - bool hasDirStore = true, - bool hasMetaStore = true, - // compat - bool hasFileStore = false, - bool hasFileDbStore = false, - }) async { - final inst = MockAppDb(); - final db = await inst._dbFactory.open( - "test.db", - version: 1, - onUpgradeNeeded: (event) async { - final db = event.database; - _createDb( - db, - hasAlbumStore: hasAlbumStore, - hasFileDb2Store: hasFileDb2Store, - hasDirStore: hasDirStore, - hasMetaStore: hasMetaStore, - hasFileStore: hasFileStore, - hasFileDbStore: hasFileDbStore, - ); - }, - ); - db.close(); - return inst; - } - - @override - Future use(Transaction Function(Database db) transactionBuilder, - FutureOr Function(Transaction transaction) fn) async { - final db = await _dbFactory.open( - "test.db", - version: 1, - onUpgradeNeeded: (event) async { - final db = event.database; - _createDb(db); - }, - ); - - Transaction? transaction; - try { - transaction = transactionBuilder(db); - return await fn(transaction); - } finally { - if (transaction != null) { - await transaction.completed; - } - db.close(); - } - } - - @override - Future delete() async { - await _dbFactory.deleteDatabase("test.db"); - } - - static void _createDb( - Database db, { - bool hasAlbumStore = true, - bool hasFileDb2Store = true, - bool hasDirStore = true, - bool hasMetaStore = true, - // compat - bool hasFileStore = false, - bool hasFileDbStore = false, - }) { - if (hasAlbumStore) { - final albumStore = db.createObjectStore(AppDb.albumStoreName); - albumStore.createIndex( - AppDbAlbumEntry.indexName, AppDbAlbumEntry.keyPath); - } - if (hasFileDb2Store) { - final file2Store = db.createObjectStore(AppDb.file2StoreName); - file2Store.createIndex(AppDbFile2Entry.strippedPathIndexName, - AppDbFile2Entry.strippedPathKeyPath); - file2Store.createIndex(AppDbFile2Entry.dateTimeEpochMsIndexName, - AppDbFile2Entry.dateTimeEpochMsKeyPath); - } - if (hasDirStore) { - db.createObjectStore(AppDb.dirStoreName); - } - if (hasMetaStore) { - db.createObjectStore(AppDb.metaStoreName, - keyPath: AppDbMetaEntry.keyPath); - } - - // compat - if (hasFileStore) { - final fileStore = db.createObjectStore(_fileStoreName); - fileStore.createIndex(_fileIndexName, _fileKeyPath); - } - if (hasFileDbStore) { - final fileDbStore = db.createObjectStore(_fileDbStoreName); - fileDbStore.createIndex(_fileDbIndexName, _fileDbKeyPath, unique: false); - } - } - - late final _dbFactory = newIdbFactoryMemory(); - - // compat only - static const _fileDbStoreName = "filesDb"; - static const _fileDbIndexName = "fileDbStore_namespacedFileId"; - static const _fileDbKeyPath = "namespacedFileId"; - - static const _fileStoreName = "files"; - static const _fileIndexName = "fileStore_path_index"; - static const _fileKeyPath = ["path", "index"]; -} - /// EventBus that ignore all events class MockEventBus implements EventBus { @override @@ -200,10 +98,10 @@ class MockEventBus implements EventBus { final _streamController = StreamController.broadcast(); } -/// Mock of [FileRepo] where all methods will throw UnimplementedError -class MockFileRepo implements FileRepo { +/// Mock of [FileDataSource] where all methods will throw UnimplementedError +class MockFileDataSource implements FileDataSource { @override - Future copy(Object account, File f, String destination, + Future copy(Account account, File f, String destination, {bool? shouldOverwrite}) { throw UnimplementedError(); } @@ -214,20 +112,17 @@ class MockFileRepo implements FileRepo { } @override - FileDataSource get dataSrc => throw UnimplementedError(); - - @override - Future getBinary(Account account, File file) { + Future getBinary(Account account, File f) { throw UnimplementedError(); } @override - Future> list(Account account, File root) async { + Future> list(Account account, File dir) { throw UnimplementedError(); } @override - Future listSingle(Account account, File root) async { + Future listSingle(Account account, File f) { throw UnimplementedError(); } @@ -243,14 +138,14 @@ class MockFileRepo implements FileRepo { } @override - Future remove(Account account, File file) { + Future remove(Account account, File f) { throw UnimplementedError(); } @override Future updateProperty( Account account, - File file, { + File f, { OrNull? metadata, OrNull? isArchived, OrNull? overrideDateTime, @@ -260,9 +155,9 @@ class MockFileRepo implements FileRepo { } } -/// [FileRepo] mock that support some ops with an internal List -class MockFileMemoryRepo extends MockFileRepo { - MockFileMemoryRepo([ +/// [FileDataSource] mock that support some ops with an internal List +class MockFileMemoryDataSource extends MockFileDataSource { + MockFileMemoryDataSource([ List initialData = const [], ]) : files = initialData.map((f) => f.copyWith()).toList() { _id = files @@ -274,7 +169,12 @@ class MockFileMemoryRepo extends MockFileRepo { @override list(Account account, File root) async { - return files.where((f) => file_util.isOrUnderDir(f, root)).toList(); + return files.where((f) => path_lib.dirname(f.path) == root.path).toList(); + } + + @override + listSingle(Account account, File file) async { + return files.where((f) => f.strippedPath == file.strippedPath).first; } @override @@ -289,9 +189,78 @@ class MockFileMemoryRepo extends MockFileRepo { } final List files; + // ignore: unused_field var _id = 0; } +class MockFileWebdavDataSource implements FileWebdavDataSource { + const MockFileWebdavDataSource(this.src); + + @override + copy(Account account, File f, String destination, {bool? shouldOverwrite}) => + src.copy(account, f, destination, shouldOverwrite: shouldOverwrite); + + @override + createDir(Account account, String path) => src.createDir(account, path); + + @override + getBinary(Account account, File f) => src.getBinary(account, f); + + @override + list(Account account, File dir, {int? depth}) async { + if (depth == 0) { + return [await src.listSingle(account, dir)]; + } else { + return src.list(account, dir); + } + } + + @override + listSingle(Account account, File f) => src.listSingle(account, f); + + @override + move(Account account, File f, String destination, {bool? shouldOverwrite}) => + src.move(account, f, destination, shouldOverwrite: shouldOverwrite); + + @override + putBinary(Account account, String path, Uint8List content) => + src.putBinary(account, path, content); + + @override + remove(Account account, File f) => src.remove(account, f); + + @override + updateProperty( + Account account, + File f, { + OrNull? metadata, + OrNull? isArchived, + OrNull? overrideDateTime, + bool? favorite, + }) => + src.updateProperty( + account, + f, + metadata: metadata, + isArchived: isArchived, + overrideDateTime: overrideDateTime, + favorite: favorite, + ); + + final MockFileMemoryDataSource src; +} + +/// [FileRepo] mock that support some ops with an internal List +class MockFileMemoryRepo extends FileRepo { + MockFileMemoryRepo([ + List initialData = const [], + ]) : super(MockFileMemoryDataSource(initialData)); + + List get files { + return (dataSrc as MockFileMemoryDataSource).files; + } +} + /// Mock of [ShareRepo] where all methods will throw UnimplementedError class MockShareRepo implements ShareRepo { @override @@ -426,6 +395,4 @@ extension MockDiContainerExtension on DiContainer { MockShareMemoryRepo get shareMemoryRepo => shareRepo as MockShareMemoryRepo; MockShareeMemoryRepo get shareeMemoryRepo => shareeRepo as MockShareeMemoryRepo; - - MockAppDb get appMemeoryDb => appDb as MockAppDb; } diff --git a/app/test/test_util.dart b/app/test/test_util.dart index e43afcc8..feb23067 100644 --- a/app/test/test_util.dart +++ b/app/test/test_util.dart @@ -1,9 +1,9 @@ import 'package:collection/collection.dart'; +import 'package:drift/drift.dart' as sql; +import 'package:drift/native.dart' as sql; import 'package:flutter/foundation.dart'; -import 'package:idb_shim/idb.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/ci_string.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/cover_provider.dart'; @@ -13,8 +13,12 @@ import 'package:nc_photos/entity/album/sort_provider.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/sharee.dart'; +import 'package:nc_photos/entity/sqlite_table.dart' as sql; +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/type.dart'; +import 'package:nc_photos/or_null.dart'; +import 'package:tuple/tuple.dart'; class FilesBuilder { FilesBuilder({ @@ -29,21 +33,25 @@ class FilesBuilder { String relativePath, { int? contentLength, String? contentType, + String? etag, DateTime? lastModified, bool isCollection = false, bool hasPreview = true, String ownerId = "admin", + Metadata? metadata, }) { files.add(File( path: "remote.php/dav/files/$relativePath", contentLength: contentLength, contentType: contentType, + etag: etag, lastModified: lastModified ?? DateTime.utc(2020, 1, 2, 3, 4, 5 + files.length), isCollection: isCollection, hasPreview: hasPreview, fileId: fileId++, ownerId: ownerId.toCi(), + metadata: metadata, )); } @@ -51,6 +59,7 @@ class FilesBuilder { String relativePath, String contentType, { int contentLength = 1024, + String? etag, DateTime? lastModified, bool hasPreview = true, String ownerId = "admin", @@ -59,6 +68,7 @@ class FilesBuilder { relativePath, contentLength: contentLength, contentType: contentType, + etag: etag, lastModified: lastModified, hasPreview: hasPreview, ownerId: ownerId, @@ -67,33 +77,62 @@ class FilesBuilder { void addJpeg( String relativePath, { int contentLength = 1024, + String? etag, DateTime? lastModified, bool hasPreview = true, String ownerId = "admin", + OrNull? metadata, }) => add( relativePath, contentLength: contentLength, contentType: "image/jpeg", + etag: etag, lastModified: lastModified, hasPreview: hasPreview, ownerId: ownerId, + metadata: metadata?.obj ?? + Metadata( + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), + imageWidth: 640, + imageHeight: 480, + ), ); void addDir( String relativePath, { int contentLength = 1024, + String? etag, DateTime? lastModified, String ownerId = "admin", }) => add( relativePath, + etag: etag, lastModified: lastModified, isCollection: true, hasPreview: false, ownerId: ownerId, ); + void addAlbumJson( + String homeDir, + String filename, { + int contentLength = 1024, + String? etag, + DateTime? lastModified, + String ownerId = "admin", + }) => + add( + "$homeDir/.com.nkming.nc_photos/albums/$filename.nc_album.json", + contentLength: contentLength, + contentType: "application/json", + etag: etag, + lastModified: lastModified, + hasPreview: false, + ownerId: ownerId, + ); + final files = []; int fileId; } @@ -339,45 +378,189 @@ Sharee buildSharee({ shareWith: shareWith, ); -Future fillAppDb( - AppDb appDb, Account account, Iterable files) async { - await appDb.use( - (db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite), - (transaction) async { - final file2Store = transaction.objectStore(AppDb.file2StoreName); - for (final f in files) { - await file2Store.put(AppDbFile2Entry.fromFile(account, f).toJson(), - AppDbFile2Entry.toPrimaryKeyForFile(account, f)); - } - }, +sql.SqliteDb buildTestDb() { + sql.driftRuntimeOptions.debugPrint = _debugPrintSql; + return sql.SqliteDb( + executor: sql.NativeDatabase.memory( + logStatements: true, + ), ); } -Future fillAppDbDir( - AppDb appDb, Account account, File dir, List children) async { - await appDb.use( - (db) => db.transaction(AppDb.dirStoreName, idbModeReadWrite), - (transaction) async { - final dirStore = transaction.objectStore(AppDb.dirStoreName); - await dirStore.put( - AppDbDirEntry.fromFiles(account, dir, children).toJson(), - AppDbDirEntry.toPrimaryKeyForDir(account, dir)); - }, - ); +Future insertFiles( + sql.SqliteDb db, Account account, Iterable files) async { + final dbAccount = await db.accountOf(account); + for (final f in files) { + final sharedQuery = db.selectOnly(db.files).join([ + sql.innerJoin( + db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId), + useColumns: false), + ]) + ..addColumns([db.files.rowId]) + ..where(db.accountFiles.account.equals(dbAccount.rowId).not()) + ..where(db.files.fileId.equals(f.fileId!)); + var rowId = (await sharedQuery.map((r) => r.read(db.files.rowId)).get()) + .firstOrNull; + final insert = SqliteFileConverter.toSql(dbAccount, f); + if (rowId == null) { + final dbFile = await db.into(db.files).insertReturning(insert.file); + rowId = dbFile.rowId; + } + final dbAccountFile = await db + .into(db.accountFiles) + .insertReturning(insert.accountFile.copyWith(file: sql.Value(rowId))); + if (insert.image != null) { + await db.into(db.images).insert( + insert.image!.copyWith(accountFile: sql.Value(dbAccountFile.rowId))); + } + if (insert.trash != null) { + await db + .into(db.trashes) + .insert(insert.trash!.copyWith(file: sql.Value(rowId))); + } + } } -Future> listAppDb( - AppDb appDb, String storeName, T Function(JsonObj) transform) { - return appDb.use( - (db) => db.transaction(storeName, idbModeReadOnly), - (transaction) async { - final store = transaction.objectStore(storeName); - return await store - .openCursor(autoAdvance: true) - .map((c) => c.value) - .cast() - .map((e) => transform(e.cast())) - .toList(); - }, - ); +Future insertDirRelation( + sql.SqliteDb db, Account account, File dir, Iterable children) async { + final dbAccount = await db.accountOf(account); + final dirRowIds = (await db + .accountFileRowIdsByFileIds([dir.fileId!], sqlAccount: dbAccount)) + .first; + final childRowIds = await db.accountFileRowIdsByFileIds( + [dir, ...children].map((f) => f.fileId!), + sqlAccount: dbAccount); + await db.batch((batch) { + batch.insertAll( + db.dirFiles, + childRowIds.map((c) => sql.DirFilesCompanion.insert( + dir: dirRowIds.fileRowId, + child: c.fileRowId, + )), + ); + }); +} + +Future insertAlbums( + sql.SqliteDb db, Account account, Iterable albums) async { + final dbAccount = await db.accountOf(account); + for (final a in albums) { + final rowIds = + await db.accountFileRowIdsOf(a.albumFile!, sqlAccount: dbAccount); + final insert = SqliteAlbumConverter.toSql(a, rowIds.fileRowId); + final dbAlbum = await db.into(db.albums).insertReturning(insert.album); + for (final s in insert.albumShares) { + await db + .into(db.albumShares) + .insert(s.copyWith(album: sql.Value(dbAlbum.rowId))); + } + } +} + +Future> listSqliteDbFiles(sql.SqliteDb db) async { + final query = db.select(db.files).join([ + sql.innerJoin( + db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId)), + sql.innerJoin( + db.accounts, db.accounts.rowId.equalsExp(db.accountFiles.account)), + sql.leftOuterJoin( + db.images, db.images.accountFile.equalsExp(db.accountFiles.rowId)), + sql.leftOuterJoin(db.trashes, db.trashes.file.equalsExp(db.files.rowId)), + ]); + return (await query + .map((r) => SqliteFileConverter.fromSql( + r.readTable(db.accounts).userId, + sql.CompleteFile( + r.readTable(db.files), + r.readTable(db.accountFiles), + r.readTableOrNull(db.images), + r.readTableOrNull(db.trashes), + ), + )) + .get()) + .toSet(); +} + +Future>> listSqliteDbDirs(sql.SqliteDb db) async { + final query = db.select(db.files).join([ + sql.innerJoin( + db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId)), + sql.innerJoin( + db.accounts, db.accounts.rowId.equalsExp(db.accountFiles.account)), + sql.leftOuterJoin( + db.images, db.images.accountFile.equalsExp(db.accountFiles.rowId)), + sql.leftOuterJoin(db.trashes, db.trashes.file.equalsExp(db.files.rowId)), + ]); + final fileMap = Map.fromEntries(await query.map((r) { + final f = sql.CompleteFile( + r.readTable(db.files), + r.readTable(db.accountFiles), + r.readTableOrNull(db.images), + r.readTableOrNull(db.trashes), + ); + return MapEntry( + f.file.rowId, + SqliteFileConverter.fromSql(r.readTable(db.accounts).userId, f), + ); + }).get()); + + final dirQuery = db.select(db.dirFiles); + final dirs = await dirQuery.map((r) => Tuple2(r.dir, r.child)).get(); + final result = >{}; + for (final d in dirs) { + (result[fileMap[d.item1]!] ??= {}).add(fileMap[d.item2]!); + } + return result; +} + +Future> listSqliteDbAlbums(sql.SqliteDb db) async { + final albumQuery = db.select(db.albums).join([ + sql.innerJoin(db.files, db.files.rowId.equalsExp(db.albums.file)), + sql.innerJoin( + db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId)), + sql.innerJoin( + db.accounts, db.accounts.rowId.equalsExp(db.accountFiles.account)), + ]); + final albums = await albumQuery.map((r) { + final albumFile = SqliteFileConverter.fromSql( + r.readTable(db.accounts).userId, + sql.CompleteFile( + r.readTable(db.files), + r.readTable(db.accountFiles), + null, + null, + ), + ); + return Tuple2( + r.read(db.albums.rowId), + SqliteAlbumConverter.fromSql(r.readTable(db.albums), albumFile, []), + ); + }).get(); + + final results = {}; + for (final a in albums) { + final shareQuery = db.select(db.albumShares) + ..where((t) => t.album.equals(a.item1)); + final dbShares = await shareQuery.get(); + results.add(a.item2.copyWith( + lastUpdated: OrNull(null), + shares: dbShares.isEmpty + ? null + : OrNull(dbShares + .map((s) => AlbumShare( + userId: s.userId.toCi(), + displayName: s.displayName, + sharedAt: s.sharedAt)) + .toList()), + )); + } + return results; +} + +bool shouldPrintSql = false; + +void _debugPrintSql(String log) { + if (shouldPrintSql) { + debugPrint(log, wrapWidth: 1024); + } } diff --git a/app/test/use_case/add_to_album_test.dart b/app/test/use_case/add_to_album_test.dart index 0434d6d9..6dc5ac9d 100644 --- a/app/test/use_case/add_to_album_test.dart +++ b/app/test/use_case/add_to_album_test.dart @@ -8,7 +8,7 @@ import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/album/sort_provider.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/use_case/add_to_album.dart'; @@ -44,13 +44,17 @@ Future _addFile() async { final album = util.AlbumBuilder().build(); final albumFile = album.albumFile!; final c = DiContainer( + fileRepo: MockFileMemoryRepo(), albumRepo: MockAlbumMemoryRepo([album]), shareRepo: MockShareRepo(), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, [file]); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider()), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, [file]); + }); await AddToAlbum(c)( account, @@ -80,7 +84,7 @@ Future _addFile() async { addedBy: "admin".toCi(), addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), file: file, - ), + ).minimize(), ], latestItemTime: DateTime.utc(2020, 1, 2, 3, 4, 5), ), @@ -106,13 +110,17 @@ Future _addExistingFile() async { final newFile = files[0].copyWith(); final albumFile = album.albumFile!; final c = DiContainer( + fileRepo: MockFileMemoryRepo(), albumRepo: MockAlbumMemoryRepo([album]), shareRepo: MockShareRepo(), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider()), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await AddToAlbum(c)( account, @@ -188,14 +196,19 @@ Future _addExistingSharedFile() async { final album = (util.AlbumBuilder()..addFileItem(files[0])).build(); final albumFile = album.albumFile!; final c = DiContainer( + fileRepo: MockFileMemoryRepo(), albumRepo: MockAlbumMemoryRepo([album]), shareRepo: MockShareRepo(), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDb(obj, user1Account, user1Files); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider()), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, account, files); + await util.insertFiles(c.sqliteDb, user1Account, user1Files); + }); await AddToAlbum(c)( account, @@ -247,17 +260,21 @@ Future _addFileToSharedAlbumOwned() async { final album = (util.AlbumBuilder()..addShare("user1")).build(); final albumFile = album.albumFile!; final c = DiContainer( + fileRepo: MockFileMemoryRepo(), albumRepo: MockAlbumMemoryRepo([album]), shareRepo: MockShareMemoryRepo([ util.buildShare(id: "0", file: albumFile, shareWith: "user1"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, [file]); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider({ "isLabEnableSharedAlbum": true, })), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, [file]); + }); await AddToAlbum(c)( account, @@ -290,17 +307,21 @@ Future _addFileOwnedByUserToSharedAlbumOwned() async { final album = (util.AlbumBuilder()..addShare("user1")).build(); final albumFile = album.albumFile!; final c = DiContainer( + fileRepo: MockFileMemoryRepo(), albumRepo: MockAlbumMemoryRepo([album]), shareRepo: MockShareMemoryRepo([ util.buildShare(id: "0", file: albumFile, shareWith: "user1"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, [file]); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider({ "isLabEnableSharedAlbum": true, })), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, [file]); + }); await AddToAlbum(c)( account, @@ -335,6 +356,7 @@ Future _addFileToMultiuserSharedAlbumNotOwned() async { .build(); final albumFile = album.albumFile!; final c = DiContainer( + fileRepo: MockFileMemoryRepo(), albumRepo: MockAlbumMemoryRepo([album]), shareRepo: MockShareMemoryRepo([ util.buildShare( @@ -342,13 +364,16 @@ Future _addFileToMultiuserSharedAlbumNotOwned() async { util.buildShare( id: "1", file: albumFile, uidOwner: "user1", shareWith: "user2"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, [file]); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider({ "isLabEnableSharedAlbum": true, })), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, [file]); + }); await AddToAlbum(c)( account, diff --git a/app/test/use_case/compat/v37_test.dart b/app/test/use_case/compat/v37_test.dart deleted file mode 100644 index fa5b1dd7..00000000 --- a/app/test/use_case/compat/v37_test.dart +++ /dev/null @@ -1,230 +0,0 @@ -import 'package:idb_shim/idb_client.dart'; -import 'package:nc_photos/app_db.dart'; -import 'package:nc_photos/list_extension.dart'; -import 'package:nc_photos/use_case/compat/v37.dart'; -import 'package:test/test.dart'; - -import '../../mock_type.dart'; -import '../../test_util.dart' as util; - -void main() { - group("CompatV37", () { - group("isAppDbNeedMigration", () { - test("w/ meta entry == false", _isAppDbNeedMigrationEntryFalse); - test("w/ meta entry == true", _isAppDbNeedMigrationEntryTrue); - test("w/o meta entry", _isAppDbNeedMigrationWithoutEntry); - }); - group("migrateAppDb", () { - test("w/o nomedia", _migrateAppDbWithoutNomedia); - test("w/ nomedia", _migrateAppDb); - test("w/ nomedia nested dir", _migrateAppDbNestedDir); - test("w/ nomedia nested no media marker", _migrateAppDbNestedMarker); - test("w/ nomedia root", _migrateAppDbRoot); - }); - }); -} - -/// Check if migration is necessary with isMigrated flag = false -/// -/// Expect: true -Future _isAppDbNeedMigrationEntryFalse() async { - final appDb = MockAppDb(); - await appDb.use( - (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), - (transaction) async { - final metaStore = transaction.objectStore(AppDb.metaStoreName); - const entry = AppDbMetaEntryCompatV37(false); - await metaStore.put(entry.toEntry().toJson()); - }, - ); - - expect(await CompatV37.isAppDbNeedMigration(appDb), true); -} - -/// Check if migration is necessary with isMigrated flag = true -/// -/// Expect: false -Future _isAppDbNeedMigrationEntryTrue() async { - final appDb = MockAppDb(); - await appDb.use( - (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), - (transaction) async { - final metaStore = transaction.objectStore(AppDb.metaStoreName); - const entry = AppDbMetaEntryCompatV37(true); - await metaStore.put(entry.toEntry().toJson()); - }, - ); - - expect(await CompatV37.isAppDbNeedMigration(appDb), false); -} - -/// Check if migration is necessary with isMigrated flag missing -/// -/// Expect: false -Future _isAppDbNeedMigrationWithoutEntry() async { - final appDb = MockAppDb(); - await appDb.use( - (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), - (transaction) async { - final metaStore = transaction.objectStore(AppDb.metaStoreName); - const entry = AppDbMetaEntryCompatV37(true); - await metaStore.put(entry.toEntry().toJson()); - }, - ); - - expect(await CompatV37.isAppDbNeedMigration(appDb), false); -} - -/// Migrate db without nomedia file -/// -/// Expect: all files remain -Future _migrateAppDbWithoutNomedia() async { - final account = util.buildAccount(); - final files = (util.FilesBuilder() - ..addDir("admin") - ..addJpeg("admin/test1.jpg") - ..addDir("admin/dir1") - ..addJpeg("admin/dir1/test2.jpg")) - .build(); - final appDb = MockAppDb(); - await util.fillAppDb(appDb, account, files); - await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3)); - await util.fillAppDbDir(appDb, account, files[2], [files[3]]); - await CompatV37.migrateAppDb(appDb); - - final fileObjs = await util.listAppDb( - appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e).file); - expect(fileObjs, files); - final dirEntries = await util.listAppDb( - appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e)); - expect(dirEntries, [ - AppDbDirEntry.fromFiles(account, files[0], files.slice(1, 3)), - AppDbDirEntry.fromFiles(account, files[2], [files[3]]), - ]); -} - -/// Migrate db with nomedia file -/// -/// nomedia: admin/dir1/.nomedia -/// Expect: files (except .nomedia) under admin/dir1 removed -Future _migrateAppDb() async { - final account = util.buildAccount(); - final files = (util.FilesBuilder() - ..addDir("admin") - ..addJpeg("admin/test1.jpg") - ..addDir("admin/dir1") - ..addGenericFile("admin/dir1/.nomedia", "text/plain") - ..addJpeg("admin/dir1/test2.jpg")) - .build(); - final appDb = MockAppDb(); - await util.fillAppDb(appDb, account, files); - await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3)); - await util.fillAppDbDir(appDb, account, files[2], files.slice(3, 5)); - await CompatV37.migrateAppDb(appDb); - - final fileObjs = await util.listAppDb( - appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e).file); - expect(fileObjs, files.slice(0, 4)); - final dirEntries = await util.listAppDb( - appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e)); - expect(dirEntries, [ - AppDbDirEntry.fromFiles(account, files[0], files.slice(1, 3)), - AppDbDirEntry.fromFiles(account, files[2], [files[3]]), - ]); -} - -/// Migrate db with nomedia file -/// -/// nomedia: admin/dir1/.nomedia -/// Expect: files (except .nomedia) under admin/dir1 removed -Future _migrateAppDbNestedDir() async { - final account = util.buildAccount(); - final files = (util.FilesBuilder() - ..addDir("admin") - ..addJpeg("admin/test1.jpg") - ..addDir("admin/dir1") - ..addGenericFile("admin/dir1/.nomedia", "text/plain") - ..addJpeg("admin/dir1/test2.jpg") - ..addDir("admin/dir1/dir1-1") - ..addJpeg("admin/dir1/dir1-1/test3.jpg")) - .build(); - final appDb = MockAppDb(); - await util.fillAppDb(appDb, account, files); - await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3)); - await util.fillAppDbDir(appDb, account, files[2], files.slice(3, 6)); - await util.fillAppDbDir(appDb, account, files[5], [files[6]]); - await CompatV37.migrateAppDb(appDb); - - final fileObjs = await util.listAppDb( - appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e).file); - expect(fileObjs, files.slice(0, 4)); - final dirEntries = await util.listAppDb( - appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e)); - expect(dirEntries, [ - AppDbDirEntry.fromFiles(account, files[0], files.slice(1, 3)), - AppDbDirEntry.fromFiles(account, files[2], [files[3]]), - ]); -} - -/// Migrate db with nomedia file -/// -/// nomedia: admin/dir1/.nomedia, admin/dir1/dir1-1/.nomedia -/// Expect: files (except admin/dir1/.nomedia) under admin/dir1 removed -Future _migrateAppDbNestedMarker() async { - final account = util.buildAccount(); - final files = (util.FilesBuilder() - ..addDir("admin") - ..addJpeg("admin/test1.jpg") - ..addDir("admin/dir1") - ..addGenericFile("admin/dir1/.nomedia", "text/plain") - ..addJpeg("admin/dir1/test2.jpg") - ..addDir("admin/dir1/dir1-1") - ..addGenericFile("admin/dir1/dir1-1/.nomedia", "text/plain") - ..addJpeg("admin/dir1/dir1-1/test3.jpg")) - .build(); - final appDb = MockAppDb(); - await util.fillAppDb(appDb, account, files); - await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3)); - await util.fillAppDbDir(appDb, account, files[2], files.slice(3, 6)); - await util.fillAppDbDir(appDb, account, files[5], files.slice(6, 8)); - await CompatV37.migrateAppDb(appDb); - - final fileObjs = await util.listAppDb( - appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e).file); - expect(fileObjs, files.slice(0, 4)); - final dirEntries = await util.listAppDb( - appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e)); - expect(dirEntries, [ - AppDbDirEntry.fromFiles(account, files[0], files.slice(1, 3)), - AppDbDirEntry.fromFiles(account, files[2], [files[3]]), - ]); -} - -/// Migrate db with nomedia file -/// -/// nomedia: admin/.nomedia -/// Expect: files (except .nomedia) under admin removed -Future _migrateAppDbRoot() async { - final account = util.buildAccount(); - final files = (util.FilesBuilder() - ..addDir("admin") - ..addGenericFile("admin/.nomedia", "text/plain") - ..addJpeg("admin/test1.jpg") - ..addDir("admin/dir1") - ..addJpeg("admin/dir1/test2.jpg")) - .build(); - final appDb = MockAppDb(); - await util.fillAppDb(appDb, account, files); - await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 4)); - await util.fillAppDbDir(appDb, account, files[3], [files[4]]); - await CompatV37.migrateAppDb(appDb); - - final objs = await util.listAppDb( - appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e).file); - expect(objs, files.slice(0, 2)); - final dirEntries = await util.listAppDb( - appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e)); - expect(dirEntries, [ - AppDbDirEntry.fromFiles(account, files[0], [files[1]]), - ]); -} diff --git a/app/test/use_case/db_compat/v5_test.dart b/app/test/use_case/db_compat/v5_test.dart deleted file mode 100644 index 9d30a77d..00000000 --- a/app/test/use_case/db_compat/v5_test.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:idb_shim/idb_client.dart'; -import 'package:nc_photos/account.dart'; -import 'package:nc_photos/app_db.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/use_case/db_compat/v5.dart'; -import 'package:test/test.dart'; - -import '../../mock_type.dart'; -import '../../test_util.dart' as util; - -void main() { - group("DbCompatV5", () { - group("isNeedMigration", () { - test("w/ meta entry == false", () async { - final appDb = MockAppDb(); - await appDb.use( - (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), - (transaction) async { - final metaStore = transaction.objectStore(AppDb.metaStoreName); - const entry = AppDbMetaEntryDbCompatV5(false); - await metaStore.put(entry.toEntry().toJson()); - }, - ); - - expect(await DbCompatV5.isNeedMigration(appDb), true); - }); - - test("w/ meta entry == true", () async { - final appDb = MockAppDb(); - await appDb.use( - (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), - (transaction) async { - final metaStore = transaction.objectStore(AppDb.metaStoreName); - const entry = AppDbMetaEntryDbCompatV5(true); - await metaStore.put(entry.toEntry().toJson()); - }, - ); - - expect(await DbCompatV5.isNeedMigration(appDb), false); - }); - - test("w/o meta entry", () async { - final appDb = MockAppDb(); - await appDb.use( - (db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite), - (transaction) async { - final metaStore = transaction.objectStore(AppDb.metaStoreName); - const entry = AppDbMetaEntryDbCompatV5(true); - await metaStore.put(entry.toEntry().toJson()); - }, - ); - - expect(await DbCompatV5.isNeedMigration(appDb), false); - }); - }); - - test("migrate", () async { - final account = util.buildAccount(); - final files = (util.FilesBuilder() - ..addJpeg( - "admin/test1.jpg", - lastModified: DateTime.utc(2020, 1, 2, 3, 4, 5), - )) - .build(); - final appDb = MockAppDb(); - await appDb.use( - (db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite), - (transaction) async { - final fileStore = transaction.objectStore(AppDb.file2StoreName); - await fileStore.put({ - "server": account.url, - "userId": account.username.toCaseInsensitiveString(), - "strippedPath": files[0].strippedPathWithEmpty, - "file": files[0].toJson(), - }, "${account.url}/${account.username.toCaseInsensitiveString()}/${files[0].fileId}"); - }, - ); - await DbCompatV5.migrate(appDb); - - final objs = - await util.listAppDb(appDb, AppDb.file2StoreName, (item) => item); - expect(objs, [ - { - "server": account.url, - "userId": account.username.toCaseInsensitiveString(), - "strippedPath": files[0].strippedPathWithEmpty, - "dateTimeEpochMs": 1577934245000, - "file": files[0].toJson(), - } - ]); - }); - }); -} diff --git a/app/test/use_case/find_file_test.dart b/app/test/use_case/find_file_test.dart index 0b3ec394..ebc4edaf 100644 --- a/app/test/use_case/find_file_test.dart +++ b/app/test/use_case/find_file_test.dart @@ -1,9 +1,8 @@ import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/use_case/find_file.dart'; import 'package:test/test.dart'; -import '../mock_type.dart'; import '../test_util.dart' as util; void main() { @@ -23,10 +22,13 @@ Future _findFile() async { ..addJpeg("admin/test2.jpg")) .build(); final c = DiContainer( - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); expect(await FindFile(c)(account, [1]), [files[1]]); } @@ -38,10 +40,13 @@ Future _findMissingFile() async { final account = util.buildAccount(); final files = (util.FilesBuilder()..addJpeg("admin/test1.jpg")).build(); final c = DiContainer( - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); expect(() => FindFile(c)(account, [1]), throwsStateError); } diff --git a/app/test/use_case/ls_test.dart b/app/test/use_case/ls_test.dart index 630bc7dd..1be9698b 100644 --- a/app/test/use_case/ls_test.dart +++ b/app/test/use_case/ls_test.dart @@ -30,7 +30,7 @@ Future _root() async { expect( await Ls(fileRepo)( account, File(path: file_util.unstripPath(account, "."))), - files.slice(1, 4), + files.slice(1, 3), ); } diff --git a/app/test/use_case/remove_album_test.dart b/app/test/use_case/remove_album_test.dart index 946ae76f..b14aecce 100644 --- a/app/test/use_case/remove_album_test.dart +++ b/app/test/use_case/remove_album_test.dart @@ -1,7 +1,7 @@ import 'package:event_bus/event_bus.dart'; import 'package:kiwi/kiwi.dart'; import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/use_case/remove_album.dart'; import 'package:test/test.dart'; @@ -35,9 +35,10 @@ Future _removeAlbum() async { albumRepo: MockAlbumMemoryRepo([album1, album2]), fileRepo: MockFileMemoryRepo([albumFile1, albumFile2]), shareRepo: MockShareRepo(), - appDb: MockAppDb(), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider()), ); + addTearDown(() => c.sqliteDb.close()); await RemoveAlbum(c)( account, c.albumMemoryRepo.findAlbumByPath(albumFile1.path)); @@ -65,11 +66,12 @@ Future _removeSharedAlbum() async { util.buildShare(id: "0", file: albumFile, shareWith: "user1"), util.buildShare(id: "1", file: files[0], shareWith: "user1"), ]), - appDb: MockAppDb(), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider({ "isLabEnableSharedAlbum": true, })), ); + addTearDown(() => c.sqliteDb.close()); await RemoveAlbum(c)( account, c.albumMemoryRepo.findAlbumByPath(albumFile.path)); @@ -106,11 +108,12 @@ Future _removeSharedAlbumFileInOtherAlbum() async { util.buildShare(id: "1", file: files[0], shareWith: "user1"), util.buildShare(id: "2", file: albumFiles[1], shareWith: "user1"), ]), - appDb: MockAppDb(), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider({ "isLabEnableSharedAlbum": true, })), ); + addTearDown(() => c.sqliteDb.close()); await RemoveAlbum(c)( account, c.albumMemoryRepo.findAlbumByPath(albumFiles[0].path)); @@ -146,14 +149,19 @@ Future _removeSharedAlbumResyncedFile() async { util.buildShare(id: "0", file: albumFile, shareWith: "user1"), util.buildShare(id: "1", file: files[0], shareWith: "user1"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDb(obj, user1Account, user1Files); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider({ "isLabEnableSharedAlbum": true, })), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, account, files); + + await util.insertFiles(c.sqliteDb, user1Account, user1Files); + }); await RemoveAlbum(c)( account, c.albumMemoryRepo.findAlbumByPath(albumFile.path)); diff --git a/app/test/use_case/remove_from_album_test.dart b/app/test/use_case/remove_from_album_test.dart index 97a470b0..cc6edc7c 100644 --- a/app/test/use_case/remove_from_album_test.dart +++ b/app/test/use_case/remove_from_album_test.dart @@ -5,7 +5,7 @@ import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/cover_provider.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/album/sort_provider.dart'; -import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/use_case/remove_from_album.dart'; import 'package:test/test.dart'; @@ -52,10 +52,13 @@ Future _removeLastFile() async { albumRepo: MockAlbumMemoryRepo([album]), fileRepo: MockFileMemoryRepo([albumFile, file1]), shareRepo: MockShareRepo(), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await RemoveFromAlbum(c)( account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItem1]); @@ -100,10 +103,13 @@ Future _remove1OfNFiles() async { albumRepo: MockAlbumMemoryRepo([album]), fileRepo: MockFileMemoryRepo([albumFile, ...files]), shareRepo: MockShareRepo(), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await RemoveFromAlbum(c)(account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItems[0]]); @@ -119,7 +125,7 @@ Future _remove1OfNFiles() async { lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), name: "test", provider: AlbumStaticProvider( - items: [fileItems[1], fileItems[2]], + items: [fileItems[1].minimize(), fileItems[2].minimize()], latestItemTime: files[2].lastModified, ), coverProvider: AlbumAutoCoverProvider(coverFile: files[2]), @@ -154,10 +160,13 @@ Future _removeLatestOfNFiles() async { albumRepo: MockAlbumMemoryRepo([album]), fileRepo: MockFileMemoryRepo([albumFile, ...files]), shareRepo: MockShareRepo(), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await RemoveFromAlbum(c)(account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItems[0]]); @@ -173,7 +182,7 @@ Future _removeLatestOfNFiles() async { lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), name: "test", provider: AlbumStaticProvider( - items: [fileItems[1], fileItems[2]], + items: [fileItems[1].minimize(), fileItems[2].minimize()], latestItemTime: files[1].lastModified, ), coverProvider: AlbumAutoCoverProvider(coverFile: files[1]), @@ -205,10 +214,13 @@ Future _removeManualCoverFile() async { albumRepo: MockAlbumMemoryRepo([album]), fileRepo: MockFileMemoryRepo([albumFile, ...files]), shareRepo: MockShareRepo(), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await RemoveFromAlbum(c)(account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItems[0]]); @@ -224,7 +236,7 @@ Future _removeManualCoverFile() async { lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5), name: "test", provider: AlbumStaticProvider( - items: [fileItems[1], fileItems[2]], + items: [fileItems[1].minimize(), fileItems[2].minimize()], latestItemTime: files[2].lastModified, ), coverProvider: AlbumAutoCoverProvider(coverFile: files[2]), @@ -256,10 +268,13 @@ Future _removeFromSharedAlbumOwned() async { util.buildShare(id: "0", file: albumFile, shareWith: "user1"), util.buildShare(id: "1", file: file1, shareWith: "user1"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await RemoveFromAlbum(c)( account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItem1]); @@ -299,10 +314,14 @@ Future _removeFromSharedAlbumOwnedWithOtherShare() async { util.buildShare( id: "3", uidOwner: "user1", file: file1, shareWith: "user2"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, user1Account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, user1Account, files); + }); await RemoveFromAlbum(c)( account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItem1]); @@ -343,10 +362,13 @@ Future _removeFromSharedAlbumOwnedLeaveExtraShare() async { util.buildShare(id: "1", file: file1, shareWith: "user1"), util.buildShare(id: "2", file: file1, shareWith: "user2"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await RemoveFromAlbum(c)( account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItem1]); @@ -389,10 +411,13 @@ Future _removeFromSharedAlbumOwnedFileInOtherAlbum() async { util.buildShare(id: "2", file: files[0], shareWith: "user2"), util.buildShare(id: "3", file: album2File, shareWith: "user1"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await RemoveFromAlbum(c)(account, c.albumMemoryRepo.findAlbumByPath(album1File.path), [album1fileItems[0]]); @@ -432,10 +457,13 @@ Future _removeFromSharedAlbumNotOwned() async { util.buildShare(id: "2", file: file1, shareWith: "user1"), util.buildShare(id: "3", file: file1, shareWith: "user2"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await RemoveFromAlbum(c)( account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItem1]); @@ -479,10 +507,13 @@ Future _removeFromSharedAlbumNotOwnedWithOwnerShare() async { util.buildShare( id: "3", uidOwner: "user1", file: file1, shareWith: "user2"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await RemoveFromAlbum(c)( account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItem1]); diff --git a/app/test/use_case/remove_test.dart b/app/test/use_case/remove_test.dart index e8e731e6..9376da73 100644 --- a/app/test/use_case/remove_test.dart +++ b/app/test/use_case/remove_test.dart @@ -5,7 +5,7 @@ import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/cover_provider.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/album/sort_provider.dart'; -import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/use_case/remove.dart'; @@ -45,11 +45,14 @@ Future _removeFile() async { albumRepo: MockAlbumMemoryRepo(), fileRepo: MockFileMemoryRepo(files), shareRepo: MockShareMemoryRepo(), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider()), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await Remove(c)(account, [files[0]]); expect(c.fileMemoryRepo.files, [files[1]]); @@ -68,11 +71,14 @@ Future _removeFileNoCleanUp() async { albumRepo: MockAlbumMemoryRepo(), fileRepo: MockFileMemoryRepo(files), shareRepo: MockShareMemoryRepo(), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider()), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await Remove(c)(account, [files[0]], shouldCleanUp: false); expect(c.fileMemoryRepo.files, [files[1]]); @@ -91,11 +97,14 @@ Future _removeAlbumFile() async { albumRepo: MockAlbumMemoryRepo([album]), fileRepo: MockFileMemoryRepo([albumFile, ...files]), shareRepo: MockShareMemoryRepo(), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider()), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await Remove(c)(account, [files[0]]); expect( @@ -132,11 +141,14 @@ Future _removeAlbumFileNoCleanUp() async { albumRepo: MockAlbumMemoryRepo([album]), fileRepo: MockFileMemoryRepo([albumFile, ...files]), shareRepo: MockShareMemoryRepo(), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider()), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await Remove(c)(account, [files[0]], shouldCleanUp: false); expect( @@ -182,11 +194,14 @@ Future _removeSharedAlbumFile() async { util.buildShare(id: "0", file: albumFile, shareWith: "user1"), util.buildShare(id: "1", file: files[0], shareWith: "user1"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider()), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await Remove(c)(account, [files[0]]); expect( @@ -246,12 +261,17 @@ Future _removeSharedAlbumSharedFile() async { id: "2", file: user1Files[0], uidOwner: "user1", shareWith: "admin"), util.buildShare(id: "3", file: files[0], shareWith: "user2"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDb(obj, user1Account, user1Files); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider()), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, account, files); + + await util.insertFiles(c.sqliteDb, user1Account, user1Files); + }); await Remove(c)(account, [files[0]]); expect( @@ -309,11 +329,14 @@ Future _removeSharedAlbumResyncedFile() async { util.buildShare(id: "0", file: albumFile, shareWith: "user1"), util.buildShare(id: "1", file: files[0], shareWith: "user1"), ]), - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), pref: Pref.scoped(PrefMemoryProvider()), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); await Remove(c)(account, [files[0]]); expect( diff --git a/app/test/use_case/scan_dir_offline_test.dart b/app/test/use_case/scan_dir_offline_test.dart index 9adf7685..54ae625c 100644 --- a/app/test/use_case/scan_dir_offline_test.dart +++ b/app/test/use_case/scan_dir_offline_test.dart @@ -1,11 +1,10 @@ import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/use_case/scan_dir_offline.dart'; import 'package:test/test.dart'; -import '../mock_type.dart'; import '../test_util.dart' as util; void main() { @@ -33,10 +32,13 @@ Future _root() async { ..addJpeg("admin/test/test2.jpg")) .build(); final c = DiContainer( - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); // convert to set because ScanDirOffline does not guarantee order expect( @@ -59,10 +61,13 @@ Future _subDir() async { ..addJpeg("admin/test/test2.jpg")) .build(); final c = DiContainer( - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); expect( (await ScanDirOffline(c)( @@ -84,10 +89,13 @@ Future _unsupportedFile() async { ..addGenericFile("admin/test2.pdf", "application/pdf")) .build(); final c = DiContainer( - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + }); // convert to set because ScanDirOffline does not guarantee order expect( @@ -118,11 +126,15 @@ Future _multiAccountRoot() async { ..addJpeg("user1/test/test2.jpg", ownerId: "user1")) .build(); final c = DiContainer( - appDb: await MockAppDb().applyFuture((obj) async { - await util.fillAppDb(obj, account, files); - await util.fillAppDb(obj, user1Account, user1Files); - }), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); + await c.sqliteDb.transaction(() async { + await c.sqliteDb.insertAccountOf(account); + await util.insertFiles(c.sqliteDb, account, files); + await c.sqliteDb.insertAccountOf(user1Account); + await util.insertFiles(c.sqliteDb, user1Account, user1Files); + }); expect( (await ScanDirOffline(c)( diff --git a/app/test/use_case/unshare_album_with_user_test.dart b/app/test/use_case/unshare_album_with_user_test.dart index a0ea82dc..077d11c3 100644 --- a/app/test/use_case/unshare_album_with_user_test.dart +++ b/app/test/use_case/unshare_album_with_user_test.dart @@ -36,8 +36,9 @@ Future _unshareWithoutFile() async { util.buildShare(id: "0", file: albumFile, shareWith: "user1"), util.buildShare(id: "1", file: albumFile, shareWith: "user2"), ]), - appDb: MockAppDb(), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); await UnshareAlbumWithUser(c)(account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), "user1".toCi()); @@ -74,8 +75,9 @@ Future _unshareWithFile() async { util.buildShare(id: "2", file: file1, shareWith: "user1"), util.buildShare(id: "3", file: file1, shareWith: "user2"), ]), - appDb: MockAppDb(), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); await UnshareAlbumWithUser(c)(account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), "user1".toCi()); @@ -123,8 +125,9 @@ Future _unshareWithFileNotOwned() async { util.buildShare( id: "5", uidOwner: "user2", file: files[1], shareWith: "user1"), ]), - appDb: MockAppDb(), + sqliteDb: util.buildTestDb(), ); + addTearDown(() => c.sqliteDb.close()); await UnshareAlbumWithUser(c)(account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), "user1".toCi()); diff --git a/app/web/sqlite3.wasm b/app/web/sqlite3.wasm new file mode 100644 index 00000000..faed541c Binary files /dev/null and b/app/web/sqlite3.wasm differ