import 'package:collection/collection.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/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/iterable_extension.dart'; 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, required this.remoteSrc, this.shouldCheckCache = false, this.forwardCacheManager, }); /// Return the cached results of listing a directory [dir] /// /// Should check [isGood] before using the cache returning by this method Future?> call(Account account, File dir) async { List? cache; try { if (forwardCacheManager != null) { cache = await forwardCacheManager!.list(account, dir); } else { cache = await appDbSrc.list(account, dir); } // compare the cached root final cacheEtag = cache.firstWhere((f) => f.compareServerIdentity(dir)).etag!; // compare the etag to see if the content has been updated var remoteEtag = dir.etag; // 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 { _isGood = true; } } else { _log.info("[call] Remote content updated for ${dir.path}"); } } on CacheNotFoundException catch (_) { // normal when there's no cache } catch (e, stackTrace) { _log.shout("[call] Cache failure", e, stackTrace); } return cache; } bool get isGood => _isGood; String? get remoteTouchToken => _remoteToken; Future _checkTouchToken( 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); } catch (e, stacktrace) { _log.shout( "[_checkTouchToken] Failed getting remote token at '$touchPath'", e, stacktrace); } _remoteToken = remoteToken; String? localToken; try { localToken = await tokenManager.getLocalToken(account, f); } catch (e, stacktrace) { _log.shout( "[_checkTouchToken] Failed getting local token at '$touchPath'", e, stacktrace); } if (localToken != remoteToken) { _log.info( "[_checkTouchToken] Remote and local token differ, cache outdated"); } else { _isGood = true; } } final AppDb appDb; final FileWebdavDataSource remoteSrc; final FileAppDbDataSource appDbSrc; final bool shouldCheckCache; final FileForwardCacheManager? forwardCacheManager; var _isGood = false; String? _remoteToken; static final _log = Logger("entity.file.file_cache_manager.FileCacheLoader"); } class FileCacheUpdater { const FileCacheUpdater(this.appDb); 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); } } Future _cacheRemote(Account account, File dir, List remote) { return appDb.use((db) async { final transaction = db.transaction( [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite); final dirStore = transaction.objectStore(AppDb.dirStoreName); final fileStore = transaction.objectStore(AppDb.file2StoreName); // 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)); }); } /// 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()}"); await appDb.use((db) async { final transaction = db.transaction( [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite); 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 AppDb appDb; static final _log = Logger("entity.file.file_cache_manager.FileCacheUpdater"); } class FileCacheRemover { const FileCacheRemover(this.appDb); /// 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 { await appDb.use((db) async { if (f.isCollection == true) { final transaction = db.transaction( [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite); final dirStore = transaction.objectStore(AppDb.dirStoreName); final fileStore = transaction.objectStore(AppDb.file2StoreName); await _removeDirFromAppDb(account, f, dirStore: dirStore, fileStore: fileStore); } else { final transaction = db.transaction(AppDb.file2StoreName, idbModeReadWrite); 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 { assert(file.isCollection != true); try { await fileStore.delete(AppDbFile2Entry.toPrimaryKeyForFile(account, file)); } catch (e, stackTrace) { _log.shout("[_removeFileFromAppDb] Failed removing fileStore entry", 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 { assert(dir.isCollection == true); // delete the dir itself try { await AppDbDirEntry.toPrimaryKeyForDir(account, dir).runFuture((key) async { _log.fine("[_removeDirFromAppDb] Removing dirStore entry: $key"); await dirStore.delete(key); }); } catch (e, stackTrace) { _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 try { await AppDbFile2Entry.toPrimaryKeyForFile(account, dir) .runFuture((key) async { _log.fine("[_removeDirFromAppDb] Removing fileStore entry: $key"); await fileStore.delete(key); }); } catch (e, stackTrace) { _log.shout( "[_removeDirFromAppDb] Failed removing fileStore entry", e, stackTrace); } // 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 _log = Logger("entity.file.file_cache_manager");