mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-01-22 16:56:19 +01:00
465 lines
13 KiB
Dart
465 lines
13 KiB
Dart
import 'dart:collection';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:equatable/equatable.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:idb_sqflite/idb_sqflite.dart';
|
|
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/entity/file.dart';
|
|
import 'package:nc_photos/exception.dart';
|
|
import 'package:nc_photos/iterable_extension.dart';
|
|
import 'package:nc_photos/list_extension.dart';
|
|
import 'package:nc_photos/use_case/get_file_binary.dart';
|
|
import 'package:nc_photos/use_case/ls.dart';
|
|
import 'package:nc_photos/use_case/put_file_binary.dart';
|
|
import 'package:path/path.dart' as path;
|
|
|
|
String getAlbumFileRoot(Account account) =>
|
|
"${api_util.getWebdavRootUrlRelative(account)}/.com.nkming.nc_photos";
|
|
|
|
bool isAlbumFile(File file) =>
|
|
path.basename(path.dirname(file.path)) == ".com.nkming.nc_photos";
|
|
|
|
List<AlbumItem> makeDistinctAlbumItems(List<AlbumItem> items) =>
|
|
items.distinctIf(
|
|
(a, b) =>
|
|
a is AlbumFileItem &&
|
|
b is AlbumFileItem &&
|
|
a.file.path == b.file.path, (a) {
|
|
if (a is AlbumFileItem) {
|
|
return a.file.path.hashCode;
|
|
} else {
|
|
return Random().nextInt(0xFFFFFFFF);
|
|
}
|
|
});
|
|
|
|
abstract class AlbumItem {
|
|
AlbumItem();
|
|
|
|
factory AlbumItem.fromJson(Map<String, dynamic> json) {
|
|
final type = json["type"];
|
|
final content = json["content"];
|
|
switch (type) {
|
|
case AlbumFileItem._type:
|
|
return AlbumFileItem.fromJson(content.cast<String, dynamic>());
|
|
default:
|
|
_log.severe("[fromJson] Unknown type: $type");
|
|
throw ArgumentError.value(type, "type");
|
|
}
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
String getType() {
|
|
if (this is AlbumFileItem) {
|
|
return AlbumFileItem._type;
|
|
} else {
|
|
throw StateError("Unknwon subtype");
|
|
}
|
|
}
|
|
|
|
return {
|
|
"type": getType(),
|
|
"content": toContentJson(),
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> toContentJson();
|
|
|
|
static final _log = Logger("entity.album.AlbumItem");
|
|
}
|
|
|
|
class AlbumFileItem extends AlbumItem with EquatableMixin {
|
|
AlbumFileItem({this.file});
|
|
|
|
factory AlbumFileItem.fromJson(Map<String, dynamic> json) {
|
|
return AlbumFileItem(
|
|
file: File.fromJson(json["file"].cast<String, dynamic>()),
|
|
);
|
|
}
|
|
|
|
@override
|
|
toString() {
|
|
return "$runtimeType {"
|
|
"file: $file"
|
|
"}";
|
|
}
|
|
|
|
@override
|
|
toContentJson() {
|
|
return {
|
|
"file": file.toJson(),
|
|
};
|
|
}
|
|
|
|
@override
|
|
get props => [
|
|
file,
|
|
];
|
|
|
|
final File file;
|
|
|
|
static const _type = "file";
|
|
}
|
|
|
|
/// Immutable object that represents an album
|
|
class Album with EquatableMixin {
|
|
Album({
|
|
DateTime lastUpdated,
|
|
@required String name,
|
|
@required List<AlbumItem> items,
|
|
this.albumFile,
|
|
}) : this.lastUpdated = (lastUpdated ?? DateTime.now()).toUtc(),
|
|
this.name = name ?? "",
|
|
this.items = UnmodifiableListView(items);
|
|
|
|
factory Album.versioned({
|
|
int version,
|
|
DateTime lastUpdated,
|
|
@required String name,
|
|
@required List<AlbumItem> items,
|
|
File albumFile,
|
|
}) {
|
|
// there's only one version right now
|
|
if (version < 2) {
|
|
return Album(
|
|
lastUpdated: lastUpdated,
|
|
name: name,
|
|
items: [],
|
|
albumFile: albumFile,
|
|
);
|
|
} else {
|
|
return Album(
|
|
lastUpdated: lastUpdated,
|
|
name: name,
|
|
items: items,
|
|
albumFile: albumFile,
|
|
);
|
|
}
|
|
}
|
|
|
|
factory Album.fromJson(Map<String, dynamic> json) {
|
|
return Album.versioned(
|
|
version: json["version"],
|
|
lastUpdated: json["lastUpdated"] == null
|
|
? null
|
|
: DateTime.parse(json["lastUpdated"]),
|
|
name: json["name"],
|
|
items: (json["items"] as List)
|
|
.map((e) => AlbumItem.fromJson(e.cast<String, dynamic>()))
|
|
.toList(),
|
|
albumFile: json["albumFile"] == null
|
|
? null
|
|
: File.fromJson(json["albumFile"].cast<String, dynamic>()),
|
|
);
|
|
}
|
|
|
|
@override
|
|
toString() {
|
|
return "$runtimeType {"
|
|
"lastUpdated: $lastUpdated, "
|
|
"name: $name, "
|
|
"items: ${items.toReadableString()}, "
|
|
"albumFile: $albumFile, "
|
|
"}";
|
|
}
|
|
|
|
/// Return a copy with specified field modified
|
|
///
|
|
/// [lastUpdated] is handled differently where if null, the current time will
|
|
/// be used. In order to keep [lastUpdated], you must explicitly assign it
|
|
/// with value from this
|
|
Album copyWith({
|
|
DateTime lastUpdated,
|
|
String name,
|
|
List<AlbumItem> items,
|
|
File albumFile,
|
|
}) {
|
|
return Album(
|
|
lastUpdated: lastUpdated,
|
|
name: name ?? this.name,
|
|
items: items ?? this.items,
|
|
albumFile: albumFile ?? this.albumFile,
|
|
);
|
|
}
|
|
|
|
@visibleForTesting
|
|
Map<String, dynamic> toRemoteJson() {
|
|
return {
|
|
"version": version,
|
|
"lastUpdated": lastUpdated.toIso8601String(),
|
|
"name": name,
|
|
"items": items.map((e) => e.toJson()).toList(),
|
|
// ignore albumFile
|
|
};
|
|
}
|
|
|
|
@visibleForTesting
|
|
Map<String, dynamic> toAppDbJson() {
|
|
return {
|
|
"version": version,
|
|
"lastUpdated": lastUpdated.toIso8601String(),
|
|
"name": name,
|
|
"items": items.map((e) => e.toJson()).toList(),
|
|
if (albumFile != null) "albumFile": albumFile.toJson(),
|
|
};
|
|
}
|
|
|
|
@override
|
|
get props => [
|
|
lastUpdated,
|
|
name,
|
|
items,
|
|
albumFile,
|
|
];
|
|
|
|
final DateTime lastUpdated;
|
|
final String name;
|
|
|
|
/// Immutable list of items. Modifying the list will result in an error
|
|
final List<AlbumItem> items;
|
|
|
|
/// How is this album stored on server
|
|
///
|
|
/// This field is typically only meaningful when returned by [AlbumRepo.get]
|
|
final File albumFile;
|
|
|
|
/// versioning of this class, use to upgrade old persisted album
|
|
static const version = 2;
|
|
}
|
|
|
|
class AlbumRepo {
|
|
AlbumRepo(this.dataSrc);
|
|
|
|
/// See [AlbumDataSource.get]
|
|
Future<Album> get(Account account, File albumFile) =>
|
|
this.dataSrc.get(account, albumFile);
|
|
|
|
/// See [AlbumDataSource.create]
|
|
Future<Album> create(Account account, Album album) =>
|
|
this.dataSrc.create(account, album);
|
|
|
|
/// See [AlbumDataSource.update]
|
|
Future<void> update(Account account, Album album) =>
|
|
this.dataSrc.update(account, album);
|
|
|
|
/// See [AlbumDataSource.cleanUp]
|
|
Future<void> cleanUp(Account account, List<File> albumFiles) =>
|
|
this.dataSrc.cleanUp(account, albumFiles);
|
|
|
|
final AlbumDataSource dataSrc;
|
|
}
|
|
|
|
abstract class AlbumDataSource {
|
|
/// Return the album defined by [albumFile]
|
|
Future<Album> get(Account account, File albumFile);
|
|
|
|
// Create a new album
|
|
Future<Album> create(Account account, Album album);
|
|
|
|
/// Update an album
|
|
Future<void> update(Account account, Album album);
|
|
|
|
/// Clean up cached albums
|
|
///
|
|
/// Remove dangling albums in cache not listed in [albumFiles]. Do nothing if
|
|
/// this data source does not cache previous results
|
|
Future<void> cleanUp(Account account, List<File> albumFiles);
|
|
}
|
|
|
|
class AlbumRemoteDataSource implements AlbumDataSource {
|
|
@override
|
|
get(Account account, File albumFile) async {
|
|
_log.info("[get] ${albumFile.path}");
|
|
final fileRepo = FileRepo(FileWebdavDataSource());
|
|
final data = await GetFileBinary(fileRepo)(account, albumFile);
|
|
try {
|
|
return Album.fromJson(jsonDecode(utf8.decode(data)))
|
|
.copyWith(albumFile: albumFile);
|
|
} catch (e, stacktrace) {
|
|
dynamic d = data;
|
|
try {
|
|
d = utf8.decode(data);
|
|
} catch (_) {}
|
|
_log.severe("[get] Invalid json data: $d", e, stacktrace);
|
|
throw FormatException("Invalid album format");
|
|
}
|
|
}
|
|
|
|
@override
|
|
create(Account account, Album album) async {
|
|
_log.info("[create]");
|
|
final fileName = _makeAlbumFileName();
|
|
final filePath = "${getAlbumFileRoot(account)}/$fileName";
|
|
final fileRepo = FileRepo(FileWebdavDataSource());
|
|
try {
|
|
await PutFileBinary(fileRepo)(
|
|
account, filePath, utf8.encode(jsonEncode(album.toRemoteJson())));
|
|
} on ApiException catch (e) {
|
|
if (e.response.statusCode == 404) {
|
|
_log.info("[create] Missing album dir, creating");
|
|
// no dir
|
|
await _createDir(account);
|
|
// then retry
|
|
await PutFileBinary(fileRepo)(
|
|
account, filePath, utf8.encode(jsonEncode(album.toRemoteJson())));
|
|
} else {
|
|
rethrow;
|
|
}
|
|
}
|
|
// query album file
|
|
final list = await Ls(fileRepo)(account, File(path: filePath),
|
|
shouldExcludeRootDir: false);
|
|
return album.copyWith(albumFile: list.first);
|
|
}
|
|
|
|
@override
|
|
update(Account account, Album album) async {
|
|
_log.info("[update] ${album.albumFile.path}");
|
|
final fileRepo = FileRepo(FileWebdavDataSource());
|
|
await PutFileBinary(fileRepo)(account, album.albumFile.path,
|
|
utf8.encode(jsonEncode(album.toRemoteJson())));
|
|
}
|
|
|
|
@override
|
|
cleanUp(Account account, List<File> 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')}.json";
|
|
}
|
|
|
|
Future<void> _createDir(Account account) {
|
|
return Api(account).files().mkcol(path: getAlbumFileRoot(account));
|
|
}
|
|
|
|
static final _log = Logger("entity.album.AlbumRemoteDataSource");
|
|
}
|
|
|
|
class AlbumAppDbDataSource implements AlbumDataSource {
|
|
@override
|
|
get(Account account, File albumFile) {
|
|
_log.info("[get] ${albumFile.path}");
|
|
return AppDb.use((db) async {
|
|
final transaction = db.transaction(AppDb.albumStoreName, idbModeReadOnly);
|
|
final store = transaction.objectStore(AppDb.albumStoreName);
|
|
final Map result =
|
|
await store.getObject("${_getCacheKey(account, albumFile)}");
|
|
if (result != null) {
|
|
return Album.fromJson(result.cast<String, dynamic>());
|
|
} else {
|
|
throw CacheNotFoundException(
|
|
"No entry: ${_getCacheKey(account, albumFile)}");
|
|
}
|
|
});
|
|
}
|
|
|
|
@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) async {
|
|
final transaction =
|
|
db.transaction(AppDb.albumStoreName, idbModeReadWrite);
|
|
final store = transaction.objectStore(AppDb.albumStoreName);
|
|
await store.put(
|
|
album.toAppDbJson(), _getCacheKey(account, album.albumFile));
|
|
});
|
|
}
|
|
|
|
@override
|
|
cleanUp(Account account, List<File> albumFiles) async {}
|
|
|
|
static final _log = Logger("entity.album.AlbumAppDbDataSource");
|
|
}
|
|
|
|
class AlbumCachedDataSource implements AlbumDataSource {
|
|
@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 ${_getCacheKey(account, albumFile)}");
|
|
return cache;
|
|
} else {
|
|
_log.info(
|
|
"[get] Remote content updated for ${_getCacheKey(account, albumFile)}");
|
|
}
|
|
} catch (e, stacktrace) {
|
|
// no cache
|
|
if (e is! CacheNotFoundException) {
|
|
_log.severe("[get] Cache failure", e, stacktrace);
|
|
}
|
|
}
|
|
|
|
// no cache
|
|
final remote = await _remoteSrc.get(account, albumFile);
|
|
await _cacheResult(account, albumFile, 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, List<File> albumFiles) async {
|
|
AppDb.use((db) async {
|
|
final transaction =
|
|
db.transaction(AppDb.albumStoreName, idbModeReadWrite);
|
|
final store = transaction.objectStore(AppDb.albumStoreName);
|
|
final keyPrefix = _getCacheKeyPrefix(account);
|
|
final range = KeyRange.bound("$keyPrefix/", "$keyPrefix/\uffff");
|
|
final danglingKeys = await store
|
|
// get all albums for this account
|
|
.openKeyCursor(range: range, autoAdvance: true)
|
|
.map((cursor) => cursor.key)
|
|
// and pick the dangling ones
|
|
.where((key) =>
|
|
!albumFiles.any((f) => key == "${_getCacheKey(account, f)}"))
|
|
.toList();
|
|
for (final k in danglingKeys) {
|
|
_log.fine("[cleanUp] Removing DB entry: $k");
|
|
await store.delete(k);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _cacheResult(Account account, File albumFile, Album result) {
|
|
return AppDb.use((db) async {
|
|
final transaction =
|
|
db.transaction(AppDb.albumStoreName, idbModeReadWrite);
|
|
final store = transaction.objectStore(AppDb.albumStoreName);
|
|
await store.put(result.toAppDbJson(), _getCacheKey(account, albumFile));
|
|
});
|
|
}
|
|
|
|
final _remoteSrc = AlbumRemoteDataSource();
|
|
final _appDbSrc = AlbumAppDbDataSource();
|
|
|
|
static final _log = Logger("entity.album.AlbumCachedDataSource");
|
|
}
|
|
|
|
String _getCacheKeyPrefix(Account account) => account.url;
|
|
|
|
String _getCacheKey(Account account, File albumFile) =>
|
|
"${_getCacheKeyPrefix(account)}/${albumFile.path}";
|