nc-photos/app/lib/entity/file/data_source.dart

902 lines
27 KiB
Dart
Raw Normal View History

2021-05-24 09:09:25 +02:00
import 'dart:convert';
import 'dart:typed_data';
import 'package:drift/drift.dart' as sql;
2021-05-24 09:09:25 +02:00
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
2021-05-24 09:09:25 +02:00
import 'package:nc_photos/entity/file.dart';
2022-02-16 08:09:26 +01:00
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;
2021-05-24 09:09:25 +02:00
import 'package:nc_photos/entity/webdav_response_parser.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/iterable_extension.dart';
2022-05-28 08:33:04 +02:00
import 'package:nc_photos/object_extension.dart';
2021-05-24 09:09:25 +02:00
import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/throttler.dart';
2021-05-24 09:09:25 +02:00
import 'package:nc_photos/touch_token_manager.dart';
import 'package:nc_photos/use_case/compat/v32.dart';
2022-01-31 10:47:37 +01:00
import 'package:path/path.dart' as path_lib;
2021-05-24 09:09:25 +02:00
import 'package:uuid/uuid.dart';
import 'package:xml/xml.dart';
class FileWebdavDataSource implements FileDataSource {
2021-08-16 21:05:00 +02:00
const FileWebdavDataSource();
2021-05-24 09:09:25 +02:00
@override
list(
Account account,
2022-02-07 13:50:52 +01:00
File dir, {
2021-07-23 22:05:57 +02:00
int? depth,
2021-05-24 09:09:25 +02:00
}) async {
2022-02-07 13:50:52 +01:00
_log.fine("[list] ${dir.path}");
return _listWithArgs(
account,
dir,
2021-05-24 09:09:25 +02:00
depth: depth,
getlastmodified: 1,
resourcetype: 1,
getetag: 1,
getcontenttype: 1,
getcontentlength: 1,
hasPreview: 1,
fileid: 1,
2022-01-25 11:08:13 +01:00
favorite: 1,
2021-06-14 12:44:38 +02:00
ownerId: 1,
ownerDisplayName: 1,
2021-08-01 22:06:28 +02:00
trashbinFilename: 1,
trashbinOriginalLocation: 1,
trashbinDeletionTime: 1,
2021-05-24 09:09:25 +02:00
customNamespaces: {
"com.nkming.nc_photos": "app",
},
customProperties: [
"app:metadata",
2021-05-28 20:45:00 +02:00
"app:is-archived",
2022-08-20 12:33:57 +02:00
"app:override-date-time",
2021-05-24 09:09:25 +02:00
],
);
}
@override
listSingle(Account account, File f) async {
_log.info("[listSingle] ${f.path}");
return (await list(account, f, depth: 0)).first;
}
@override
listMinimal(
Account account,
File dir, {
int? depth,
}) {
_log.fine("[listMinimal] ${dir.path}");
return _listWithArgs(
account,
dir,
depth: depth,
getlastmodified: 1,
resourcetype: 1,
getcontenttype: 1,
fileid: 1,
);
}
2021-05-24 09:09:25 +02:00
@override
remove(Account account, File f) async {
_log.info("[remove] ${f.path}");
final response = await Api(account).files().delete(path: f.path);
if (!response.isGood) {
_log.severe("[remove] Failed requesting server: $response");
throw ApiException(
response: response,
2022-07-21 07:45:49 +02:00
message:
"Server responed with an error: HTTP ${response.statusCode}");
2021-05-24 09:09:25 +02:00
}
}
@override
getBinary(Account account, File f) async {
_log.info("[getBinary] ${f.path}");
final response = await Api(account).files().get(path: f.path);
if (!response.isGood) {
_log.severe("[getBinary] Failed requesting server: $response");
throw ApiException(
response: response,
2022-07-21 07:45:49 +02:00
message:
"Server responed with an error: HTTP ${response.statusCode}");
2021-05-24 09:09:25 +02:00
}
return response.body;
}
@override
putBinary(Account account, String path, Uint8List content) async {
_log.info("[putBinary] $path");
final response =
await Api(account).files().put(path: path, content: content);
if (!response.isGood) {
_log.severe("[putBinary] Failed requesting server: $response");
throw ApiException(
response: response,
2022-07-21 07:45:49 +02:00
message:
"Server responed with an error: HTTP ${response.statusCode}");
2021-05-24 09:09:25 +02:00
}
}
@override
2021-05-28 19:15:09 +02:00
updateProperty(
Account account,
File f, {
2021-07-23 22:05:57 +02:00
OrNull<Metadata>? metadata,
OrNull<bool>? isArchived,
OrNull<DateTime>? overrideDateTime,
2022-01-25 11:17:19 +01:00
bool? favorite,
2021-05-28 19:15:09 +02:00
}) async {
_log.info("[updateProperty] ${f.path}");
2021-07-23 22:05:57 +02:00
if (metadata?.obj != null && metadata!.obj!.fileEtag != f.etag) {
2021-05-24 09:09:25 +02:00
_log.warning(
2021-07-23 22:05:57 +02:00
"[updateProperty] Metadata etag mismatch (metadata: ${metadata.obj!.fileEtag}, file: ${f.etag})");
2021-05-24 09:09:25 +02:00
}
final setProps = {
2021-05-28 19:15:09 +02:00
if (metadata?.obj != null)
2021-07-23 22:05:57 +02:00
"app:metadata": jsonEncode(metadata!.obj!.toJson()),
if (isArchived?.obj != null) "app:is-archived": isArchived!.obj,
2021-06-21 12:39:17 +02:00
if (overrideDateTime?.obj != null)
2021-07-02 21:45:47 +02:00
"app:override-date-time":
2021-07-23 22:05:57 +02:00
overrideDateTime!.obj!.toUtc().toIso8601String(),
2022-01-25 11:17:19 +01:00
if (favorite != null) "oc:favorite": favorite ? 1 : 0,
2021-05-24 09:09:25 +02:00
};
final removeProps = [
2021-07-23 22:05:57 +02:00
if (OrNull.isSetNull(metadata)) "app:metadata",
if (OrNull.isSetNull(isArchived)) "app:is-archived",
if (OrNull.isSetNull(overrideDateTime)) "app:override-date-time",
2021-05-24 09:09:25 +02:00
];
final response = await Api(account).files().proppatch(
path: f.path,
namespaces: {
"com.nkming.nc_photos": "app",
2022-01-25 11:17:19 +01:00
"http://owncloud.org/ns": "oc",
2021-05-24 09:09:25 +02:00
},
set: setProps.isNotEmpty ? setProps : null,
remove: removeProps.isNotEmpty ? removeProps : null,
);
if (!response.isGood) {
2021-05-28 19:15:09 +02:00
_log.severe("[updateProperty] Failed requesting server: $response");
2021-05-24 09:09:25 +02:00
throw ApiException(
response: response,
2022-07-21 07:45:49 +02:00
message:
"Server responed with an error: HTTP ${response.statusCode}");
2021-05-24 09:09:25 +02:00
}
}
@override
copy(
Account account,
File f,
String destination, {
2021-07-23 22:05:57 +02:00
bool? shouldOverwrite,
2021-05-24 09:09:25 +02:00
}) async {
_log.info("[copy] ${f.path} to $destination");
final response = await Api(account).files().copy(
path: f.path,
destinationUrl: "${account.url}/$destination",
overwrite: shouldOverwrite,
);
if (!response.isGood) {
_log.severe("[copy] Failed requesting sever: $response");
throw ApiException(
response: response,
2022-07-21 07:45:49 +02:00
message:
"Server responed with an error: HTTP ${response.statusCode}");
} else if (response.statusCode == 204) {
// conflict
throw ApiException(
response: response,
2022-07-21 07:45:49 +02:00
message:
"Server responed with an error: HTTP ${response.statusCode}");
2021-05-24 09:09:25 +02:00
}
}
@override
move(
Account account,
File f,
String destination, {
2021-07-23 22:05:57 +02:00
bool? shouldOverwrite,
2021-05-24 09:09:25 +02:00
}) async {
_log.info("[move] ${f.path} to $destination");
final response = await Api(account).files().move(
path: f.path,
destinationUrl: "${account.url}/$destination",
overwrite: shouldOverwrite,
);
if (!response.isGood) {
_log.severe("[move] Failed requesting sever: $response");
throw ApiException(
response: response,
2022-07-21 07:45:49 +02:00
message:
"Server responed with an error: HTTP ${response.statusCode}");
2021-05-24 09:09:25 +02:00
}
}
@override
createDir(Account account, String path) async {
_log.info("[createDir] $path");
final response = await Api(account).files().mkcol(
path: path,
);
if (!response.isGood) {
_log.severe("[createDir] Failed requesting sever: $response");
throw ApiException(
response: response,
2022-07-21 07:45:49 +02:00
message:
"Server responed with an error: HTTP ${response.statusCode}");
2021-05-24 09:09:25 +02:00
}
}
Future<List<File>> _listWithArgs(
Account account,
File dir, {
int? depth,
getlastmodified,
getetag,
getcontenttype,
resourcetype,
getcontentlength,
id,
fileid,
favorite,
commentsHref,
commentsCount,
commentsUnread,
ownerId,
ownerDisplayName,
shareTypes,
checksums,
hasPreview,
size,
richWorkspace,
trashbinFilename,
trashbinOriginalLocation,
trashbinDeletionTime,
Map<String, String>? customNamespaces,
List<String>? customProperties,
}) async {
final response = await Api(account).files().propfind(
path: dir.path,
depth: depth,
getlastmodified: getlastmodified,
getetag: getetag,
getcontenttype: getcontenttype,
resourcetype: resourcetype,
getcontentlength: getcontentlength,
id: id,
fileid: fileid,
favorite: favorite,
commentsHref: commentsHref,
commentsCount: commentsCount,
commentsUnread: commentsUnread,
ownerId: ownerId,
ownerDisplayName: ownerDisplayName,
shareTypes: shareTypes,
checksums: checksums,
hasPreview: hasPreview,
size: size,
richWorkspace: richWorkspace,
trashbinFilename: trashbinFilename,
trashbinOriginalLocation: trashbinOriginalLocation,
trashbinDeletionTime: trashbinDeletionTime,
customNamespaces: customNamespaces,
customProperties: customProperties,
);
if (!response.isGood) {
_log.severe("[list] Failed requesting server: $response");
throw ApiException(
response: response,
message:
"Server responed with an error: HTTP ${response.statusCode}");
}
final xml = XmlDocument.parse(response.body);
var files = await WebdavResponseParser().parseFiles(xml);
// _log.fine("[list] Parsed files: [$files]");
bool hasNoMediaMarker = false;
files = files
.forEachLazy((f) {
if (file_util.isNoMediaMarker(f)) {
hasNoMediaMarker = true;
}
})
.where((f) => _validateFile(f))
.map((e) {
if (e.metadata == null || e.metadata!.fileEtag == e.etag) {
return e;
} else {
_log.info("[list] Ignore outdated metadata for ${e.path}");
return e.copyWith(metadata: OrNull(null));
}
})
.toList();
await _compatUpgrade(account, files);
if (hasNoMediaMarker) {
// return only the marker and the dir itself
return files
.where((f) =>
dir.compareServerIdentity(f) || file_util.isNoMediaMarker(f))
.toList();
} else {
return files;
}
}
Future<void> _compatUpgrade(Account account, List<File> files) async {
for (final f in files.where((element) => element.metadata?.exif != null)) {
if (CompatV32.isExifNeedMigration(f.metadata!.exif!)) {
final newExif = CompatV32.migrateExif(f.metadata!.exif!, f.path);
await updateProperty(
account,
f,
metadata: OrNull(f.metadata!.copyWith(
exif: newExif,
)),
);
}
}
}
2021-05-24 09:09:25 +02:00
static final _log = Logger("entity.file.data_source.FileWebdavDataSource");
}
class FileSqliteDbDataSource implements FileDataSource {
FileSqliteDbDataSource(this._c);
2021-11-01 10:50:13 +01:00
2021-05-24 09:09:25 +02:00
@override
2022-06-06 12:02:54 +02:00
list(Account account, File dir) async {
_log.info("[list] ${dir.path}");
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;
2021-05-24 09:09:25 +02:00
}
@override
listSingle(Account account, File f) {
_log.severe("[listSingle] ${f.path}");
throw UnimplementedError();
}
@override
listMinimal(Account account, File dir) => list(account, dir);
2022-01-15 11:35:15 +01:00
/// List files with date between [fromEpochMs] (inclusive) and [toEpochMs]
/// (exclusive)
Future<List<File>> listByDate(
Account account, int fromEpochMs, int toEpochMs) async {
_log.info("[listByDate] [$fromEpochMs, $toEpochMs]");
final dbFiles = await _c.sqliteDb.use((db) async {
final query = db.queryFiles().run((q) {
q.setQueryMode(sql.FilesQueryMode.completeFile);
q.setAppAccount(account);
for (final r in account.roots) {
if (r.isNotEmpty) {
q.byOrRelativePathPattern("$r/%");
}
}
return q.build();
});
final dateTime = db.accountFiles.bestDateTime.secondsSinceEpoch;
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);
2022-01-15 11:35:15 +01:00
}
2021-05-24 09:09:25 +02:00
@override
2022-02-16 08:09:26 +01:00
remove(Account account, File f) {
2021-05-24 09:09:25 +02:00
_log.info("[remove] ${f.path}");
return FileSqliteCacheRemover(_c)(account, f);
2021-05-24 09:09:25 +02:00
}
@override
getBinary(Account account, File f) {
_log.severe("[getBinary] ${f.path}");
2021-05-24 09:09:25 +02:00
throw UnimplementedError();
}
@override
putBinary(Account account, String path, Uint8List content) async {
_log.info("[putBinary] $path");
// do nothing, we currently don't store file contents locally
}
@override
2022-08-20 12:33:57 +02:00
updateProperty(
Account account,
File f, {
OrNull<Metadata>? metadata,
OrNull<bool>? isArchived,
OrNull<DateTime>? overrideDateTime,
bool? favorite,
}) async {
2021-05-28 19:15:09 +02:00
_log.info("[updateProperty] ${f.path}");
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),
);
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),
));
}
}
});
2021-05-24 09:09:25 +02:00
}
@override
copy(
Account account,
File f,
String destination, {
2021-07-23 22:05:57 +02:00
bool? shouldOverwrite,
2021-05-24 09:09:25 +02:00
}) async {
// do nothing
}
@override
move(
Account account,
File f,
String destination, {
2021-07-23 22:05:57 +02:00
bool? shouldOverwrite,
2021-05-24 09:09:25 +02:00
}) async {
// do nothing
}
@override
createDir(Account account, String path) async {
// do nothing
}
/// Remove all children of [dir] but not [dir] itself
Future<void> emptyDir(Account account, File dir) {
_log.info("[emptyDir] ${dir.path}");
return FileSqliteCacheEmptier(_c)(account, dir);
}
final DiContainer _c;
2021-11-01 10:50:13 +01:00
static final _log = Logger("entity.file.data_source.FileSqliteDbDataSource");
2021-05-24 09:09:25 +02:00
}
class FileCachedDataSource implements FileDataSource {
2021-11-01 10:50:13 +01:00
FileCachedDataSource(
this._c, {
2021-05-24 09:09:25 +02:00
this.shouldCheckCache = false,
this.forwardCacheManager,
}) : _sqliteDbSrc = FileSqliteDbDataSource(_c);
2021-05-24 09:09:25 +02:00
@override
2022-02-07 13:50:52 +01:00
list(Account account, File dir) async {
2022-02-16 08:09:26 +01:00
final cacheLoader = FileCacheLoader(
_c,
cacheSrc: _sqliteDbSrc,
2021-05-24 09:09:25 +02:00
remoteSrc: _remoteSrc,
shouldCheckCache: shouldCheckCache,
forwardCacheManager: forwardCacheManager,
2021-05-24 09:09:25 +02:00
);
2022-02-16 08:09:26 +01:00
final cache = await cacheLoader(account, dir);
if (cacheLoader.isGood) {
2021-07-23 22:05:57 +02:00
return cache!;
2021-05-24 09:09:25 +02:00
}
// no cache or outdated
try {
2022-02-07 13:50:52 +01:00
final remote = await _remoteSrc.list(account, dir);
await FileSqliteCacheUpdater(_c)(account, dir, remote: remote);
2021-05-24 09:09:25 +02:00
if (shouldCheckCache) {
// update our local touch token to match the remote one
final tokenManager = TouchTokenManager(_c);
2021-05-24 09:09:25 +02:00
try {
await tokenManager.setLocalToken(
2022-02-16 08:09:26 +01:00
account, dir, cacheLoader.remoteTouchToken);
2021-05-24 09:09:25 +02:00
} catch (e, stacktrace) {
_log.shout("[list] Failed while setLocalToken", e, stacktrace);
// ignore error
}
}
return remote;
} on ApiException catch (e) {
if (e.response.statusCode == 404) {
2022-02-07 13:50:52 +01:00
_log.info("[list] File removed: $dir");
if (cache != null) {
await _sqliteDbSrc.remove(account, dir);
}
2021-05-24 09:09:25 +02:00
return [];
2022-05-16 14:02:30 +02:00
} else if (e.response.statusCode == 403) {
_log.info("[list] E2E encrypted dir: $dir");
if (cache != null) {
// we need to keep the dir itself as it'll be inserted again on next
// listing of its parent
await _sqliteDbSrc.emptyDir(account, dir);
}
2022-05-16 14:02:30 +02:00
return [];
2021-05-24 09:09:25 +02:00
} else {
rethrow;
}
}
}
@override
listSingle(Account account, File f) async {
final remote = await _remoteSrc.listSingle(account, f);
if (remote.isCollection != true) {
// only update regular files
_log.info("[listSingle] Cache single file: ${logFilename(f.path)}");
await FileSqliteCacheUpdater(_c).updateSingle(account, remote);
}
return remote;
}
@override
listMinimal(Account account, File dir) {
return _remoteSrc.listMinimal(account, dir);
}
2021-05-24 09:09:25 +02:00
@override
remove(Account account, File f) async {
await _sqliteDbSrc.remove(account, f);
2021-05-24 09:09:25 +02:00
await _remoteSrc.remove(account, f);
}
@override
getBinary(Account account, File f) {
return _remoteSrc.getBinary(account, f);
}
@override
putBinary(Account account, String path, Uint8List content) async {
await _remoteSrc.putBinary(account, path, content);
}
@override
2021-05-28 19:15:09 +02:00
updateProperty(
Account account,
File f, {
2021-07-23 22:05:57 +02:00
OrNull<Metadata>? metadata,
OrNull<bool>? isArchived,
OrNull<DateTime>? overrideDateTime,
2022-01-25 11:17:19 +01:00
bool? favorite,
2021-05-28 19:15:09 +02:00
}) async {
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,
);
2021-05-24 09:09:25 +02:00
// generate a new random token
2021-09-15 08:58:06 +02:00
final token = const Uuid().v4().replaceAll("-", "");
2022-01-31 10:47:37 +01:00
final dir = File(path: path_lib.dirname(f.path));
await TouchTokenManager(_c).setLocalToken(account, dir, token);
// don't update remote token that frequently
(_touchTokenThrottlers["${account.url}/${dir.path}"] ??= Throttler(
onTriggered: _updateRemoteTouchToken,
logTag: "FileCachedDataSource._touchTokenThrottlers",
))
.trigger(
maxResponceTime: const Duration(seconds: 20),
maxPendingCount: 20,
data: _TouchTokenThrottlerData(account, dir, token),
);
2021-05-24 09:09:25 +02:00
}
@override
copy(
Account account,
File f,
String destination, {
2021-07-23 22:05:57 +02:00
bool? shouldOverwrite,
2021-05-24 09:09:25 +02:00
}) async {
await _remoteSrc.copy(account, f, destination,
shouldOverwrite: shouldOverwrite);
}
@override
move(
Account account,
File f,
String destination, {
2021-07-23 22:05:57 +02:00
bool? shouldOverwrite,
2021-05-24 09:09:25 +02:00
}) async {
await _remoteSrc.move(account, f, destination,
shouldOverwrite: shouldOverwrite);
}
@override
createDir(Account account, String path) async {
await _remoteSrc.createDir(account, path);
}
Future<void> updateRemoteTouchTokenNow() async {
for (final t in _touchTokenThrottlers.values) {
await t.triggerNow();
}
}
Future<void> _updateRemoteTouchToken(
List<_TouchTokenThrottlerData> data) async {
try {
final d = data.last;
await TouchTokenManager(_c).setRemoteToken(d.account, d.dir, d.token);
} catch (e, stackTrace) {
_log.shout("[_updateRemoteTouchToken] Failed while setRemoteToken", e,
stackTrace);
}
}
final DiContainer _c;
2021-05-24 09:09:25 +02:00
final bool shouldCheckCache;
final FileForwardCacheManager? forwardCacheManager;
2021-05-24 09:09:25 +02:00
2021-09-15 08:58:06 +02:00
final _remoteSrc = const FileWebdavDataSource();
final FileSqliteDbDataSource _sqliteDbSrc;
2021-05-24 09:09:25 +02:00
final _touchTokenThrottlers = <String, Throttler<_TouchTokenThrottlerData>>{};
2021-05-24 09:09:25 +02:00
static final _log = Logger("entity.file.data_source.FileCachedDataSource");
}
class _TouchTokenThrottlerData {
const _TouchTokenThrottlerData(this.account, this.dir, this.token);
final Account account;
final File dir;
final String token;
}
/// Forward cache for listing AppDb dirs
///
/// It's very expensive to list a dir and its sub-dirs one by one in multiple
/// queries. This class will instead query every sub-dirs when a new dir is
/// 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._c, Map<int, File> knownFiles) {
_fileCache.addAll(knownFiles);
}
static bool require(DiContainer c) => true;
2022-05-28 08:33:04 +02:00
/// Transform a list of files to a map suitable to be passed as the
/// [knownFiles] argument
static Map<int, File> prepareFileMap(List<File> knownFiles) => knownFiles
.where((f) => f.fileId != null)
.map((e) => MapEntry(e.fileId!, e))
.run((obj) => Map.fromEntries(obj));
Future<List<File>> list(Account account, File dir) async {
// check cache
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 _listByFileIds(dir, childFileIds);
}
Future<void> _cacheDir(Account account, File dir) async {
await _c.sqliteDb.use((db) async {
final dbAccount = await db.accountOf(account);
final dirCache = <String, List<int>>{};
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<void> _fillDirCacheForDir(
sql.SqliteDb db,
Map<String, List<int>> 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 children = await _queryChildOfDir(db, dbAccount, myDirRowId);
if (children.isEmpty) {
// no cache
return;
2022-05-28 08:33:04 +02:00
}
final childFileIds = children.map((c) => c.fileId).toList();
dirCache[dirRelativePath] = childFileIds;
// 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);
}
}
2022-05-28 08:33:04 +02:00
Future<void> _fillFileCache(sql.SqliteDb db, Account account,
sql.Account dbAccount, Iterable<int> fileIds) async {
final needQuery = fileIds.where((id) => !_fileCache.containsKey(id));
2022-05-28 08:33:04 +02:00
if (needQuery.isNotEmpty) {
_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");
2022-05-28 08:33:04 +02:00
}
}
List<File> _listByFileIds(File dir, List<int> 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();
}
Future<int?> _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<List<_ForwardCacheQueryChildResult>> _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 = <String, List<int>>{};
final _fileCache = <int, File>{};
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;
}
2021-07-16 11:25:01 +02:00
bool _validateFile(File f) {
// See: https://gitlab.com/nkming2/nc-photos/-/issues/9
return f.lastModified != null;
}