Merge branch 'collection-rewrite'

This commit is contained in:
Ming Ming 2023-05-21 22:58:33 +08:00
commit 23cf6cacca
317 changed files with 18124 additions and 11677 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

View file

@ -8,7 +8,7 @@ import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/exception.dart';
import 'package:np_api/np_api.dart';
import 'package:np_api/np_api.dart' hide NcAlbumItem;
import 'package:to_string/to_string.dart';
part 'api_util.g.dart';
@ -45,6 +45,11 @@ String getFilePreviewUrlRelative(
if (file_util.isTrash(account, file)) {
// trashbin does not support preview.png endpoint
url = "index.php/apps/files_trashbin/preview?fileId=${file.fdId}";
} else if (file_util.isNcAlbumFile(account, file)) {
// We can't use the generic file preview url because collaborative albums do
// not create a file share for photos not belonging to you, that means you
// can only access the file view the Photos API
url = "apps/photos/api/v1/preview/${file.fdId}?x=$width&y=$height";
} else {
url = "index.php/core/preview?fileId=${file.fdId}";
}
@ -76,6 +81,16 @@ String getFilePreviewUrlByFileId(
return url;
}
/// Return the preview image URL for [fileId], using the new Photos API in
/// Nextcloud 25
String getPhotosApiFilePreviewUrlByFileId(
Account account,
int fileId, {
required int width,
required int height,
}) =>
"${account.url}/apps/photos/api/v1/preview/$fileId?x=$width&y=$height";
String getFileUrl(Account account, FileDescriptor file) {
return "${account.url}/${getFileUrlRelative(file)}";
}

View file

@ -4,7 +4,10 @@ import 'package:logging/logging.dart';
import 'package:nc_photos/entity/face.dart';
import 'package:nc_photos/entity/favorite.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/nc_album.dart';
import 'package:nc_photos/entity/nc_album_item.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/entity/server_status.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/sharee.dart';
import 'package:nc_photos/entity/tag.dart';
@ -34,7 +37,6 @@ class ApiFavoriteConverter {
}
}
@npLog
class ApiFileConverter {
static File fromApi(api.File file) {
final metadata = file.customProperties?["com.nkming.nc_photos:metadata"]
@ -78,20 +80,45 @@ class ApiFileConverter {
?.run((obj) => ImageLocation.fromJson(jsonDecode(obj))),
);
}
}
static String _hrefToPath(String href) {
final rawPath = href.trimLeftAny("/");
final pos = rawPath.indexOf("remote.php");
if (pos == -1) {
// what?
_log.warning("[_hrefToPath] Unknown href value: $rawPath");
return rawPath;
} else {
return rawPath.substring(pos);
}
class ApiNcAlbumConverter {
static NcAlbum fromApi(api.NcAlbum album) {
return NcAlbum(
path: _hrefToPath(album.href),
lastPhoto: (album.lastPhoto ?? -1) < 0 ? null : album.lastPhoto,
nbItems: album.nbItems ?? 0,
location: album.location,
dateStart: (album.dateRange?["start"] as int?)
?.run((d) => DateTime.fromMillisecondsSinceEpoch(d * 1000)),
dateEnd: (album.dateRange?["end"] as int?)
?.run((d) => DateTime.fromMillisecondsSinceEpoch(d * 1000)),
collaborators: album.collaborators
.map((c) => NcAlbumCollaborator(
id: c.id.toCi(),
label: c.label,
type: c.type,
))
.toList(),
);
}
}
static final _log = _$ApiFileConverterNpLog.log;
class ApiNcAlbumItemConverter {
static NcAlbumItem fromApi(api.NcAlbumItem item) {
return NcAlbumItem(
path: _hrefToPath(item.href),
fileId: item.fileId!,
contentLength: item.contentLength,
contentType: item.contentType,
etag: item.etag,
lastModified: item.lastModified,
hasPreview: item.hasPreview,
isFavorite: item.favorite,
fileMetadataWidth: item.fileMetadataSize?["width"],
fileMetadataHeight: item.fileMetadataSize?["height"],
);
}
}
class ApiPersonConverter {
@ -153,6 +180,16 @@ class ApiShareeConverter {
};
}
class ApiStatusConverter {
static ServerStatus fromApi(api.Status status) {
return ServerStatus(
versionRaw: status.version,
versionName: status.versionString,
productName: status.productName,
);
}
}
class ApiTagConverter {
static Tag fromApi(api.Tag tag) {
return Tag(
@ -171,3 +208,19 @@ class ApiTaggedFileConverter {
);
}
}
String _hrefToPath(String href) {
final rawPath = href.trimLeftAny("/");
final pos = rawPath.indexOf("remote.php");
if (pos == -1) {
// what?
_$_NpLog.log.warning("[_hrefToPath] Unknown href value: $rawPath");
return rawPath;
} else {
return rawPath.substring(pos);
}
}
@npLog
// ignore: camel_case_types
class _ {}

View file

@ -6,9 +6,9 @@ part of 'entity_converter.dart';
// NpLogGenerator
// **************************************************************************
extension _$ApiFileConverterNpLog on ApiFileConverter {
extension _$_NpLog on _ {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("api.entity_converter.ApiFileConverter");
static final log = Logger("api.entity_converter._");
}

View file

@ -8,6 +8,8 @@ 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/album/data_source2.dart';
import 'package:nc_photos/entity/album/repo2.dart';
import 'package:nc_photos/entity/face.dart';
import 'package:nc_photos/entity/face/data_source.dart';
import 'package:nc_photos/entity/favorite.dart';
@ -16,6 +18,8 @@ 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/local_file/data_source.dart';
import 'package:nc_photos/entity/nc_album/data_source.dart';
import 'package:nc_photos/entity/nc_album/repo.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/entity/person/data_source.dart';
import 'package:nc_photos/entity/search.dart';
@ -198,7 +202,12 @@ Future<void> _initDiContainer(InitIsolateType isolateType) async {
c.sqliteDb = await _createDb(isolateType);
c.albumRepo = AlbumRepo(AlbumCachedDataSource(c));
c.albumRepoRemote = AlbumRepo(AlbumRemoteDataSource());
c.albumRepoLocal = AlbumRepo(AlbumSqliteDbDataSource(c));
c.albumRepo2 = CachedAlbumRepo2(
const AlbumRemoteDataSource2(), AlbumSqliteDbDataSource2(c.sqliteDb));
c.albumRepo2Remote = const BasicAlbumRepo2(AlbumRemoteDataSource2());
c.albumRepo2Local = BasicAlbumRepo2(AlbumSqliteDbDataSource2(c.sqliteDb));
c.faceRepo = const FaceRepo(FaceRemoteDataSource());
c.fileRepo = FileRepo(FileCachedDataSource(c));
c.fileRepoRemote = const FileRepo(FileWebdavDataSource());
@ -214,6 +223,11 @@ Future<void> _initDiContainer(InitIsolateType isolateType) async {
c.tagRepoLocal = TagRepo(TagSqliteDbDataSource(c.sqliteDb));
c.taggedFileRepo = const TaggedFileRepo(TaggedFileRemoteDataSource());
c.searchRepo = SearchRepo(SearchSqliteDbDataSource(c));
c.ncAlbumRepo = CachedNcAlbumRepo(
const NcAlbumRemoteDataSource(), NcAlbumSqliteDbDataSource(c.sqliteDb));
c.ncAlbumRepoRemote = const BasicNcAlbumRepo(NcAlbumRemoteDataSource());
c.ncAlbumRepoLocal = BasicNcAlbumRepo(NcAlbumSqliteDbDataSource(c.sqliteDb));
c.touchManager = TouchManager(c);
if (platform_k.isAndroid) {

2
app/lib/asset.dart Normal file
View file

@ -0,0 +1,2 @@
const icAddCollectionsOutlined24 =
"assets/ic_add_collections_outlined_24dp.png";

View file

@ -4,12 +4,14 @@ import 'package:flutter/foundation.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/controller/collections_controller.dart';
import 'package:nc_photos/controller/server_controller.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/use_case/list_album.dart';
import 'package:nc_photos/use_case/collection/list_collection.dart';
import 'package:nc_photos/use_case/list_location_group.dart';
import 'package:nc_photos/use_case/list_person.dart';
import 'package:nc_photos/use_case/list_tag.dart';
@ -24,13 +26,13 @@ part 'home_search_suggestion.g.dart';
abstract class HomeSearchResult {}
@toString
class HomeSearchAlbumResult implements HomeSearchResult {
const HomeSearchAlbumResult(this.album);
class HomeSearchCollectionResult implements HomeSearchResult {
const HomeSearchCollectionResult(this.collection);
@override
String toString() => _$toString();
final Album album;
final Collection collection;
}
@toString
@ -125,7 +127,8 @@ class HomeSearchSuggestionBlocFailure extends HomeSearchSuggestionBlocState {
@npLog
class HomeSearchSuggestionBloc
extends Bloc<HomeSearchSuggestionBlocEvent, HomeSearchSuggestionBlocState> {
HomeSearchSuggestionBloc(this.account)
HomeSearchSuggestionBloc(
this.account, this.collectionsController, this.serverController)
: super(const HomeSearchSuggestionBlocInit()) {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
@ -187,13 +190,21 @@ class HomeSearchSuggestionBloc
Emitter<HomeSearchSuggestionBlocState> emit) async {
final product = <_Searcheable>[];
try {
final albums = await ListAlbum(_c)(account)
.where((event) => event is Album)
var collections = collectionsController
.peekStream()
.data
.map((e) => e.collection)
.toList();
product.addAll(albums.map((a) => _AlbumSearcheable(a)));
_log.info("[_onEventPreloadData] Loaded ${albums.length} albums");
if (collections.isEmpty) {
collections = await ListCollection(_c,
serverController: serverController)(account)
.last;
}
product.addAll(collections.map(_CollectionSearcheable.new));
_log.info(
"[_onEventPreloadData] Loaded ${collections.length} collections");
} catch (e) {
_log.warning("[_onEventPreloadData] Failed while ListAlbum", e);
_log.warning("[_onEventPreloadData] Failed while ListCollection", e);
}
try {
final tags = await ListTag(_c)(account);
@ -239,6 +250,8 @@ class HomeSearchSuggestionBloc
}
final Account account;
final CollectionsController collectionsController;
final ServerController serverController;
late final DiContainer _c;
final _search = Woozy<_Searcheable>(limit: 10);
@ -249,16 +262,16 @@ abstract class _Searcheable {
HomeSearchResult toResult();
}
class _AlbumSearcheable implements _Searcheable {
const _AlbumSearcheable(this.album);
class _CollectionSearcheable implements _Searcheable {
const _CollectionSearcheable(this.collection);
@override
toKeywords() => [album.name.toCi()];
toKeywords() => [collection.name.toCi()];
@override
toResult() => HomeSearchAlbumResult(album);
toResult() => HomeSearchCollectionResult(collection);
final Album album;
final Collection collection;
}
class _TagSearcheable implements _Searcheable {

View file

@ -18,10 +18,10 @@ extension _$HomeSearchSuggestionBlocNpLog on HomeSearchSuggestionBloc {
// ToStringGenerator
// **************************************************************************
extension _$HomeSearchAlbumResultToString on HomeSearchAlbumResult {
extension _$HomeSearchCollectionResultToString on HomeSearchCollectionResult {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "HomeSearchAlbumResult {album: $album}";
return "HomeSearchCollectionResult {collection: $collection}";
}
}

View file

@ -1,338 +0,0 @@
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
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/album.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';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/exception_event.dart';
import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util;
import 'package:nc_photos/throttler.dart';
import 'package:nc_photos/use_case/list_album.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:to_string/to_string.dart';
part 'list_album.g.dart';
class ListAlbumBlocItem {
ListAlbumBlocItem(this.album);
final Album album;
}
abstract class ListAlbumBlocEvent {
const ListAlbumBlocEvent();
}
@toString
class ListAlbumBlocQuery extends ListAlbumBlocEvent {
const ListAlbumBlocQuery(this.account);
@override
String toString() => _$toString();
final Account account;
}
/// An external event has happened and may affect the state of this bloc
@toString
class _ListAlbumBlocExternalEvent extends ListAlbumBlocEvent {
const _ListAlbumBlocExternalEvent();
@override
String toString() => _$toString();
}
@toString
abstract class ListAlbumBlocState {
const ListAlbumBlocState(this.account, this.items);
@override
String toString() => _$toString();
final Account? account;
final List<ListAlbumBlocItem> items;
}
class ListAlbumBlocInit extends ListAlbumBlocState {
const ListAlbumBlocInit() : super(null, const []);
}
class ListAlbumBlocLoading extends ListAlbumBlocState {
const ListAlbumBlocLoading(Account? account, List<ListAlbumBlocItem> items)
: super(account, items);
}
class ListAlbumBlocSuccess extends ListAlbumBlocState {
const ListAlbumBlocSuccess(Account? account, List<ListAlbumBlocItem> items)
: super(account, items);
}
@toString
class ListAlbumBlocFailure extends ListAlbumBlocState {
const ListAlbumBlocFailure(
Account? account, List<ListAlbumBlocItem> items, this.exception)
: super(account, items);
@override
String toString() => _$toString();
final dynamic exception;
}
/// The state of this bloc is inconsistent. This typically means that the data
/// may have been changed externally
class ListAlbumBlocInconsistent extends ListAlbumBlocState {
const ListAlbumBlocInconsistent(
Account? account, List<ListAlbumBlocItem> items)
: super(account, items);
}
@npLog
class ListAlbumBloc extends Bloc<ListAlbumBlocEvent, ListAlbumBlocState> {
/// Constructor
///
/// If [offlineC] is not null, this [DiContainer] will be used when requesting
/// offline contents, otherwise [_c] will be used
ListAlbumBloc(
this._c, [
DiContainer? offlineC,
]) : _offlineC = offlineC ?? _c,
assert(require(_c)),
assert(offlineC == null || require(offlineC)),
assert(ListAlbum.require(_c)),
assert(offlineC == null || ListAlbum.require(offlineC)),
super(const ListAlbumBlocInit()) {
_albumUpdatedListener =
AppEventListener<AlbumUpdatedEvent>(_onAlbumUpdatedEvent);
_fileRemovedListener =
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
_albumCreatedListener =
AppEventListener<AlbumCreatedEvent>(_onAlbumCreatedEvent);
_albumUpdatedListener.begin();
_fileRemovedListener.begin();
_albumCreatedListener.begin();
_fileMovedListener.begin();
_shareCreatedListener.begin();
_shareRemovedListener.begin();
_refreshThrottler = Throttler(
onTriggered: (_) {
add(const _ListAlbumBlocExternalEvent());
},
logTag: "ListAlbumBloc.refresh",
);
on<ListAlbumBlocEvent>(_onEvent);
}
static bool require(DiContainer c) => true;
static ListAlbumBloc of(Account account) {
final name = bloc_util.getInstNameForAccount("ListAlbumBloc", account);
try {
_log.fine("[of] Resolving bloc for '$name'");
return KiwiContainer().resolve<ListAlbumBloc>(name);
} catch (_) {
// no created instance for this account, make a new one
_log.info("[of] New bloc instance for account: $account");
final c = KiwiContainer().resolve<DiContainer>();
final offlineC = c.copyWith(
fileRepo: OrNull(c.fileRepoLocal),
albumRepo: OrNull(c.albumRepoLocal),
);
final bloc = ListAlbumBloc(c, offlineC);
KiwiContainer().registerInstance<ListAlbumBloc>(bloc, name: name);
return bloc;
}
}
@override
close() {
_albumUpdatedListener.end();
_fileRemovedListener.end();
_albumCreatedListener.end();
_fileMovedListener.end();
_shareCreatedListener.end();
_shareRemovedListener.end();
_refreshThrottler.clear();
return super.close();
}
Future<void> _onEvent(
ListAlbumBlocEvent event, Emitter<ListAlbumBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is ListAlbumBlocQuery) {
await _onEventQuery(event, emit);
} else if (event is _ListAlbumBlocExternalEvent) {
await _onExternalEvent(event, emit);
}
}
Future<void> _onEventQuery(
ListAlbumBlocQuery ev, Emitter<ListAlbumBlocState> emit) async {
emit(ListAlbumBlocLoading(ev.account, state.items));
bool hasContent = state.items.isNotEmpty;
if (!hasContent) {
// show something instantly on first load
final cacheState = await _queryOffline(ev);
emit(ListAlbumBlocLoading(ev.account, cacheState.items));
hasContent = cacheState.items.isNotEmpty;
}
final newState = await _queryOnline(ev);
if (newState is ListAlbumBlocFailure) {
emit(ListAlbumBlocFailure(
ev.account,
newState.items.isNotEmpty ? newState.items : state.items,
newState.exception));
} else {
emit(newState);
}
}
Future<void> _onExternalEvent(
_ListAlbumBlocExternalEvent ev, Emitter<ListAlbumBlocState> emit) async {
emit(ListAlbumBlocInconsistent(state.account, state.items));
}
void _onAlbumUpdatedEvent(AlbumUpdatedEvent ev) {
if (state is ListAlbumBlocInit) {
// no data in this bloc, ignore
return;
}
if (_isAccountOfInterest(ev.account)) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
}
}
void _onFileRemovedEvent(FileRemovedEvent ev) {
if (state is ListAlbumBlocInit) {
// no data in this bloc, ignore
return;
}
if (_isAccountOfInterest(ev.account) &&
file_util.isAlbumFile(ev.account, ev.file)) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
}
}
void _onFileMovedEvent(FileMovedEvent ev) {
if (state is ListAlbumBlocInit) {
// no data in this bloc, ignore
return;
}
if (_isAccountOfInterest(ev.account)) {
if (ev.destination
.startsWith(remote_storage_util.getRemoteAlbumsDir(ev.account)) ||
ev.file.path
.startsWith(remote_storage_util.getRemoteAlbumsDir(ev.account))) {
// moving from/to album dir
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
}
}
}
void _onAlbumCreatedEvent(AlbumCreatedEvent ev) {
if (state is ListAlbumBlocInit) {
// no data in this bloc, ignore
return;
}
if (_isAccountOfInterest(ev.account)) {
add(const _ListAlbumBlocExternalEvent());
}
}
void _onShareCreatedEvent(ShareCreatedEvent ev) =>
_onShareChanged(ev.account, ev.share);
void _onShareRemovedEvent(ShareRemovedEvent ev) =>
_onShareChanged(ev.account, ev.share);
void _onShareChanged(Account account, Share share) {
if (_isAccountOfInterest(account)) {
final webdavPath = file_util.unstripPath(account, share.path);
if (webdavPath
.startsWith(remote_storage_util.getRemoteAlbumsDir(account))) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
}
}
}
Future<ListAlbumBlocState> _queryOffline(ListAlbumBlocQuery ev) =>
_queryWithAlbumDataSource(_offlineC, ev);
Future<ListAlbumBlocState> _queryOnline(ListAlbumBlocQuery ev) =>
_queryWithAlbumDataSource(_c, ev);
Future<ListAlbumBlocState> _queryWithAlbumDataSource(
DiContainer c, ListAlbumBlocQuery ev) async {
try {
final albums = <Album>[];
final errors = <dynamic>[];
await for (final result in ListAlbum(c)(ev.account)) {
if (result is ExceptionEvent) {
if (result.error is CacheNotFoundException) {
_log.info(
"[_queryWithAlbumDataSource] Cache not found", result.error);
} else {
_log.shout("[_queryWithAlbumDataSource] Exception while ListAlbum",
result.error, result.stackTrace);
}
errors.add(result.error);
} else if (result is Album) {
albums.add(result);
}
}
final items = albums.map((e) => ListAlbumBlocItem(e)).toList();
if (errors.isEmpty) {
return ListAlbumBlocSuccess(ev.account, items);
} else {
return ListAlbumBlocFailure(ev.account, items, errors.first);
}
} catch (e, stacktrace) {
_log.severe("[_queryWithAlbumDataSource] Exception", e, stacktrace);
return ListAlbumBlocFailure(ev.account, [], e);
}
}
bool _isAccountOfInterest(Account account) =>
state.account == null || state.account!.compareServerIdentity(account);
final DiContainer _c;
final DiContainer _offlineC;
late AppEventListener<AlbumUpdatedEvent> _albumUpdatedListener;
late AppEventListener<FileRemovedEvent> _fileRemovedListener;
late AppEventListener<AlbumCreatedEvent> _albumCreatedListener;
late final _fileMovedListener =
AppEventListener<FileMovedEvent>(_onFileMovedEvent);
late final _shareCreatedListener =
AppEventListener<ShareCreatedEvent>(_onShareCreatedEvent);
late final _shareRemovedListener =
AppEventListener<ShareRemovedEvent>(_onShareRemovedEvent);
late Throttler _refreshThrottler;
static final _log = _$ListAlbumBlocNpLog.log;
}

View file

@ -1,46 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'list_album.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$ListAlbumBlocNpLog on ListAlbumBloc {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("bloc.list_album.ListAlbumBloc");
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$ListAlbumBlocQueryToString on ListAlbumBlocQuery {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "ListAlbumBlocQuery {account: $account}";
}
}
extension _$_ListAlbumBlocExternalEventToString on _ListAlbumBlocExternalEvent {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_ListAlbumBlocExternalEvent {}";
}
}
extension _$ListAlbumBlocStateToString on ListAlbumBlocState {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "${objectRuntimeType(this, "ListAlbumBlocState")} {account: $account, items: [length: ${items.length}]}";
}
}
extension _$ListAlbumBlocFailureToString on ListAlbumBlocFailure {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "ListAlbumBlocFailure {account: $account, items: [length: ${items.length}], exception: $exception}";
}
}

View file

@ -1,222 +0,0 @@
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.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/entity/person.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/throttler.dart';
import 'package:nc_photos/use_case/populate_person.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:to_string/to_string.dart';
part 'list_face_file.g.dart';
abstract class ListFaceFileBlocEvent {
const ListFaceFileBlocEvent();
}
@toString
class ListFaceFileBlocQuery extends ListFaceFileBlocEvent {
const ListFaceFileBlocQuery(this.account, this.person);
@override
String toString() => _$toString();
final Account account;
final Person person;
}
/// An external event has happened and may affect the state of this bloc
@toString
class _ListFaceFileBlocExternalEvent extends ListFaceFileBlocEvent {
const _ListFaceFileBlocExternalEvent();
@override
String toString() => _$toString();
}
@toString
abstract class ListFaceFileBlocState {
const ListFaceFileBlocState(this.account, this.items);
@override
String toString() => _$toString();
final Account? account;
final List<File> items;
}
class ListFaceFileBlocInit extends ListFaceFileBlocState {
ListFaceFileBlocInit() : super(null, const []);
}
class ListFaceFileBlocLoading extends ListFaceFileBlocState {
const ListFaceFileBlocLoading(Account? account, List<File> items)
: super(account, items);
}
class ListFaceFileBlocSuccess extends ListFaceFileBlocState {
const ListFaceFileBlocSuccess(Account? account, List<File> items)
: super(account, items);
}
@toString
class ListFaceFileBlocFailure extends ListFaceFileBlocState {
const ListFaceFileBlocFailure(
Account? account, List<File> items, this.exception)
: super(account, items);
@override
String toString() => _$toString();
final Object exception;
}
/// The state of this bloc is inconsistent. This typically means that the data
/// may have been changed externally
class ListFaceFileBlocInconsistent extends ListFaceFileBlocState {
const ListFaceFileBlocInconsistent(Account? account, List<File> items)
: super(account, items);
}
/// List all people recognized in an account
@npLog
class ListFaceFileBloc
extends Bloc<ListFaceFileBlocEvent, ListFaceFileBlocState> {
ListFaceFileBloc(this._c)
: assert(require(_c)),
assert(PopulatePerson.require(_c)),
super(ListFaceFileBlocInit()) {
_fileRemovedEventListener.begin();
_filePropertyUpdatedEventListener.begin();
on<ListFaceFileBlocEvent>(_onEvent);
}
static bool require(DiContainer c) => DiContainer.has(c, DiType.faceRepo);
@override
close() {
_fileRemovedEventListener.end();
_filePropertyUpdatedEventListener.end();
return super.close();
}
Future<void> _onEvent(
ListFaceFileBlocEvent event, Emitter<ListFaceFileBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is ListFaceFileBlocQuery) {
await _onEventQuery(event, emit);
} else if (event is _ListFaceFileBlocExternalEvent) {
await _onExternalEvent(event, emit);
}
}
Future<void> _onEventQuery(
ListFaceFileBlocQuery ev, Emitter<ListFaceFileBlocState> emit) async {
try {
emit(ListFaceFileBlocLoading(ev.account, state.items));
emit(ListFaceFileBlocSuccess(ev.account, await _query(ev)));
} catch (e, stackTrace) {
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
emit(ListFaceFileBlocFailure(ev.account, state.items, e));
}
}
Future<void> _onExternalEvent(_ListFaceFileBlocExternalEvent ev,
Emitter<ListFaceFileBlocState> emit) async {
emit(ListFaceFileBlocInconsistent(state.account, state.items));
}
void _onFileRemovedEvent(FileRemovedEvent ev) {
if (state is ListFaceFileBlocInit) {
// no data in this bloc, ignore
return;
}
if (_isFileOfInterest(ev.file)) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
}
}
void _onFilePropertyUpdatedEvent(FilePropertyUpdatedEvent ev) {
if (!ev.hasAnyProperties([
FilePropertyUpdatedEvent.propMetadata,
FilePropertyUpdatedEvent.propIsArchived,
FilePropertyUpdatedEvent.propOverrideDateTime,
FilePropertyUpdatedEvent.propFavorite,
])) {
// not interested
return;
}
if (state is ListFaceFileBlocInit) {
// no data in this bloc, ignore
return;
}
if (!_isFileOfInterest(ev.file)) {
return;
}
if (ev.hasAnyProperties([
FilePropertyUpdatedEvent.propIsArchived,
FilePropertyUpdatedEvent.propOverrideDateTime,
FilePropertyUpdatedEvent.propFavorite,
])) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
} else {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 10),
maxPendingCount: 10,
);
}
}
Future<List<File>> _query(ListFaceFileBlocQuery ev) async {
final faces = await _c.faceRepo.list(ev.account, ev.person);
final files = await PopulatePerson(_c)(ev.account, faces);
final rootDirs = ev.account.roots
.map((e) => File(path: file_util.unstripPath(ev.account, e)))
.toList();
return files
.where((f) =>
file_util.isSupportedFormat(f) &&
rootDirs.any((dir) => file_util.isUnderDir(f, dir)))
.toList();
}
bool _isFileOfInterest(File file) {
if (!file_util.isSupportedFormat(file)) {
return false;
}
for (final r in state.account?.roots ?? []) {
final dir = File(path: file_util.unstripPath(state.account!, r));
if (file_util.isUnderDir(file, dir)) {
return true;
}
}
return false;
}
final DiContainer _c;
late final _fileRemovedEventListener =
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
late final _filePropertyUpdatedEventListener =
AppEventListener<FilePropertyUpdatedEvent>(_onFilePropertyUpdatedEvent);
late final _refreshThrottler = Throttler(
onTriggered: (_) {
add(const _ListFaceFileBlocExternalEvent());
},
logTag: "ListFaceFileBloc.refresh",
);
}

View file

@ -1,47 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'list_face_file.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$ListFaceFileBlocNpLog on ListFaceFileBloc {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("bloc.list_face_file.ListFaceFileBloc");
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$ListFaceFileBlocQueryToString on ListFaceFileBlocQuery {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "ListFaceFileBlocQuery {account: $account, person: $person}";
}
}
extension _$_ListFaceFileBlocExternalEventToString
on _ListFaceFileBlocExternalEvent {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_ListFaceFileBlocExternalEvent {}";
}
}
extension _$ListFaceFileBlocStateToString on ListFaceFileBlocState {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "${objectRuntimeType(this, "ListFaceFileBlocState")} {account: $account, items: [length: ${items.length}]}";
}
}
extension _$ListFaceFileBlocFailureToString on ListFaceFileBlocFailure {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "ListFaceFileBlocFailure {account: $account, items: [length: ${items.length}], exception: $exception}";
}
}

View file

@ -10,7 +10,7 @@ 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/remote_storage_util.dart' as remote_storage_util;
import 'package:nc_photos/use_case/list_album.dart';
import 'package:nc_photos/use_case/album/list_album.dart';
import 'package:nc_photos/use_case/ls.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:to_string/to_string.dart';

View file

@ -4,6 +4,7 @@ import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_descriptor.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';
@ -147,7 +148,7 @@ class ListLocationBloc
Future<LocationGroupResult> _query(ListLocationBlocQuery ev) =>
ListLocationGroup(_c.withLocalRepo())(ev.account);
bool _isFileOfInterest(File file) {
bool _isFileOfInterest(FileDescriptor file) {
if (!file_util.isSupportedFormat(file)) {
return false;
}

View file

@ -1,181 +0,0 @@
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.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/event/event.dart';
import 'package:nc_photos/throttler.dart';
import 'package:nc_photos/use_case/list_location_file.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:to_string/to_string.dart';
part 'list_location_file.g.dart';
abstract class ListLocationFileBlocEvent {
const ListLocationFileBlocEvent();
}
@toString
class ListLocationFileBlocQuery extends ListLocationFileBlocEvent {
const ListLocationFileBlocQuery(this.account, this.place, this.countryCode);
@override
String toString() => _$toString();
final Account account;
final String? place;
final String countryCode;
}
/// An external event has happened and may affect the state of this bloc
@toString
class _ListLocationFileBlocExternalEvent extends ListLocationFileBlocEvent {
const _ListLocationFileBlocExternalEvent();
@override
String toString() => _$toString();
}
@toString
abstract class ListLocationFileBlocState {
const ListLocationFileBlocState(this.account, this.items);
@override
String toString() => _$toString();
final Account? account;
final List<File> items;
}
class ListLocationFileBlocInit extends ListLocationFileBlocState {
ListLocationFileBlocInit() : super(null, const []);
}
class ListLocationFileBlocLoading extends ListLocationFileBlocState {
const ListLocationFileBlocLoading(Account? account, List<File> items)
: super(account, items);
}
class ListLocationFileBlocSuccess extends ListLocationFileBlocState {
const ListLocationFileBlocSuccess(Account? account, List<File> items)
: super(account, items);
}
@toString
class ListLocationFileBlocFailure extends ListLocationFileBlocState {
const ListLocationFileBlocFailure(
Account? account, List<File> items, this.exception)
: super(account, items);
@override
String toString() => _$toString();
final Object exception;
}
/// The state of this bloc is inconsistent. This typically means that the data
/// may have been changed externally
class ListLocationFileBlocInconsistent extends ListLocationFileBlocState {
const ListLocationFileBlocInconsistent(Account? account, List<File> items)
: super(account, items);
}
/// List all files associated with a specific tag
@npLog
class ListLocationFileBloc
extends Bloc<ListLocationFileBlocEvent, ListLocationFileBlocState> {
ListLocationFileBloc(this._c)
: assert(require(_c)),
assert(ListLocationFile.require(_c)),
super(ListLocationFileBlocInit()) {
_fileRemovedEventListener.begin();
on<ListLocationFileBlocEvent>(_onEvent);
}
static bool require(DiContainer c) =>
DiContainer.has(c, DiType.taggedFileRepo);
@override
close() {
_fileRemovedEventListener.end();
return super.close();
}
Future<void> _onEvent(ListLocationFileBlocEvent event,
Emitter<ListLocationFileBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is ListLocationFileBlocQuery) {
await _onEventQuery(event, emit);
} else if (event is _ListLocationFileBlocExternalEvent) {
await _onExternalEvent(event, emit);
}
}
Future<void> _onEventQuery(ListLocationFileBlocQuery ev,
Emitter<ListLocationFileBlocState> emit) async {
try {
emit(ListLocationFileBlocLoading(ev.account, state.items));
emit(ListLocationFileBlocSuccess(ev.account, await _query(ev)));
} catch (e, stackTrace) {
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
emit(ListLocationFileBlocFailure(ev.account, state.items, e));
}
}
Future<void> _onExternalEvent(_ListLocationFileBlocExternalEvent ev,
Emitter<ListLocationFileBlocState> emit) async {
emit(ListLocationFileBlocInconsistent(state.account, state.items));
}
void _onFileRemovedEvent(FileRemovedEvent ev) {
if (state is ListLocationFileBlocInit) {
// no data in this bloc, ignore
return;
}
if (_isFileOfInterest(ev.file)) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
}
}
Future<List<File>> _query(ListLocationFileBlocQuery ev) async {
final files = <File>[];
for (final r in ev.account.roots) {
final dir = File(path: file_util.unstripPath(ev.account, r));
files.addAll(await ListLocationFile(_c)(
ev.account, dir, ev.place, ev.countryCode));
}
return files.where((f) => file_util.isSupportedFormat(f)).toList();
}
bool _isFileOfInterest(File file) {
if (!file_util.isSupportedFormat(file)) {
return false;
}
for (final r in state.account?.roots ?? []) {
final dir = File(path: file_util.unstripPath(state.account!, r));
if (file_util.isUnderDir(file, dir)) {
return true;
}
}
return false;
}
final DiContainer _c;
late final _fileRemovedEventListener =
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
late final _refreshThrottler = Throttler(
onTriggered: (_) {
add(const _ListLocationFileBlocExternalEvent());
},
logTag: "ListLocationFileBloc.refresh",
);
}

View file

@ -1,47 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'list_location_file.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$ListLocationFileBlocNpLog on ListLocationFileBloc {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("bloc.list_location_file.ListLocationFileBloc");
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$ListLocationFileBlocQueryToString on ListLocationFileBlocQuery {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "ListLocationFileBlocQuery {account: $account, place: $place, countryCode: $countryCode}";
}
}
extension _$_ListLocationFileBlocExternalEventToString
on _ListLocationFileBlocExternalEvent {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_ListLocationFileBlocExternalEvent {}";
}
}
extension _$ListLocationFileBlocStateToString on ListLocationFileBlocState {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "${objectRuntimeType(this, "ListLocationFileBlocState")} {account: $account, items: [length: ${items.length}]}";
}
}
extension _$ListLocationFileBlocFailureToString on ListLocationFileBlocFailure {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "ListLocationFileBlocFailure {account: $account, items: [length: ${items.length}], exception: $exception}";
}
}

View file

@ -1,96 +0,0 @@
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/share/data_source.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:to_string/to_string.dart';
part 'list_share.g.dart';
abstract class ListShareBlocEvent {
const ListShareBlocEvent();
}
@toString
class ListShareBlocQuery extends ListShareBlocEvent {
const ListShareBlocQuery(this.account, this.file);
@override
String toString() => _$toString();
final Account account;
final File file;
}
@toString
abstract class ListShareBlocState {
const ListShareBlocState(this.account, this.file, this.items);
@override
String toString() => _$toString();
final Account? account;
final File file;
final List<Share> items;
}
class ListShareBlocInit extends ListShareBlocState {
ListShareBlocInit() : super(null, File(path: ""), const []);
}
class ListShareBlocLoading extends ListShareBlocState {
const ListShareBlocLoading(Account? account, File file, List<Share> items)
: super(account, file, items);
}
class ListShareBlocSuccess extends ListShareBlocState {
const ListShareBlocSuccess(Account? account, File file, List<Share> items)
: super(account, file, items);
}
@toString
class ListShareBlocFailure extends ListShareBlocState {
const ListShareBlocFailure(
Account? account, File file, List<Share> items, this.exception)
: super(account, file, items);
@override
String toString() => _$toString();
final dynamic exception;
}
/// List all shares from a given file
@npLog
class ListShareBloc extends Bloc<ListShareBlocEvent, ListShareBlocState> {
ListShareBloc() : super(ListShareBlocInit()) {
on<ListShareBlocEvent>(_onEvent);
}
Future<void> _onEvent(
ListShareBlocEvent event, Emitter<ListShareBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is ListShareBlocQuery) {
await _onEventQuery(event, emit);
}
}
Future<void> _onEventQuery(
ListShareBlocQuery ev, Emitter<ListShareBlocState> emit) async {
try {
emit(ListShareBlocLoading(ev.account, ev.file, state.items));
emit(ListShareBlocSuccess(ev.account, ev.file, await _query(ev)));
} catch (e, stackTrace) {
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
emit(ListShareBlocFailure(ev.account, ev.file, state.items, e));
}
}
Future<List<Share>> _query(ListShareBlocQuery ev) {
final shareRepo = ShareRepo(ShareRemoteDataSource());
return shareRepo.list(ev.account, ev.file);
}
}

View file

@ -1,39 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'list_share.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$ListShareBlocNpLog on ListShareBloc {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("bloc.list_share.ListShareBloc");
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$ListShareBlocQueryToString on ListShareBlocQuery {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "ListShareBlocQuery {account: $account, file: ${file.path}}";
}
}
extension _$ListShareBlocStateToString on ListShareBlocState {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "${objectRuntimeType(this, "ListShareBlocState")} {account: $account, file: ${file.path}, items: [length: ${items.length}]}";
}
}
extension _$ListShareBlocFailureToString on ListShareBlocFailure {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "ListShareBlocFailure {account: $account, file: ${file.path}, items: [length: ${items.length}], exception: $exception}";
}
}

View file

@ -1,111 +0,0 @@
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
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/entity/sharee.dart';
import 'package:nc_photos/entity/sharee/data_source.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:to_string/to_string.dart';
part 'list_sharee.g.dart';
abstract class ListShareeBlocEvent {
const ListShareeBlocEvent();
}
@toString
class ListShareeBlocQuery extends ListShareeBlocEvent {
const ListShareeBlocQuery(this.account);
@override
String toString() => _$toString();
final Account account;
}
@toString
abstract class ListShareeBlocState {
const ListShareeBlocState(this.account, this.items);
@override
String toString() => _$toString();
final Account? account;
final List<Sharee> items;
}
class ListShareeBlocInit extends ListShareeBlocState {
ListShareeBlocInit() : super(null, const []);
}
class ListShareeBlocLoading extends ListShareeBlocState {
const ListShareeBlocLoading(Account? account, List<Sharee> items)
: super(account, items);
}
class ListShareeBlocSuccess extends ListShareeBlocState {
const ListShareeBlocSuccess(Account? account, List<Sharee> items)
: super(account, items);
}
@toString
class ListShareeBlocFailure extends ListShareeBlocState {
const ListShareeBlocFailure(
Account? account, List<Sharee> items, this.exception)
: super(account, items);
@override
String toString() => _$toString();
final dynamic exception;
}
/// List all sharees of this account
@npLog
class ListShareeBloc extends Bloc<ListShareeBlocEvent, ListShareeBlocState> {
ListShareeBloc() : super(ListShareeBlocInit()) {
on<ListShareeBlocEvent>(_onEvent);
}
static ListShareeBloc of(Account account) {
final name = bloc_util.getInstNameForAccount("ListShareeBloc", account);
try {
_log.fine("[of] Resolving bloc for '$name'");
return KiwiContainer().resolve<ListShareeBloc>(name);
} catch (_) {
// no created instance for this account, make a new one
_log.info("[of] New bloc instance for account: $account");
final bloc = ListShareeBloc();
KiwiContainer().registerInstance<ListShareeBloc>(bloc, name: name);
return bloc;
}
}
Future<void> _onEvent(
ListShareeBlocEvent event, Emitter<ListShareeBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is ListShareeBlocQuery) {
await _onEventQuery(event, emit);
}
}
Future<void> _onEventQuery(
ListShareeBlocQuery ev, Emitter<ListShareeBlocState> emit) async {
try {
emit(ListShareeBlocLoading(ev.account, state.items));
emit(ListShareeBlocSuccess(ev.account, await _query(ev)));
} catch (e, stackTrace) {
_log.shout("[_onEventQuery] Exception while request", e, stackTrace);
emit(ListShareeBlocFailure(ev.account, state.items, e));
}
}
Future<List<Sharee>> _query(ListShareeBlocQuery ev) {
final shareeRepo = ShareeRepo(ShareeRemoteDataSource());
return shareeRepo.list(ev.account);
}
static final _log = _$ListShareeBlocNpLog.log;
}

View file

@ -1,39 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'list_sharee.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$ListShareeBlocNpLog on ListShareeBloc {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("bloc.list_sharee.ListShareeBloc");
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$ListShareeBlocQueryToString on ListShareeBlocQuery {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "ListShareeBlocQuery {account: $account}";
}
}
extension _$ListShareeBlocStateToString on ListShareeBlocState {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "${objectRuntimeType(this, "ListShareeBlocState")} {account: $account, items: [length: ${items.length}]}";
}
}
extension _$ListShareeBlocFailureToString on ListShareeBlocFailure {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "ListShareeBlocFailure {account: $account, items: [length: ${items.length}], exception: $exception}";
}
}

View file

@ -304,6 +304,7 @@ class ListSharingBloc extends Bloc<ListSharingBlocEvent, ListSharingBlocState> {
s,
File(
path: webdavPath,
fileId: s.itemSource,
isCollection: true,
),
);

View file

@ -1,226 +0,0 @@
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.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/entity/tag.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/throttler.dart';
import 'package:nc_photos/use_case/find_file.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:to_string/to_string.dart';
part 'list_tag_file.g.dart';
abstract class ListTagFileBlocEvent {
const ListTagFileBlocEvent();
}
@toString
class ListTagFileBlocQuery extends ListTagFileBlocEvent {
const ListTagFileBlocQuery(this.account, this.tag);
@override
String toString() => _$toString();
final Account account;
final Tag tag;
}
/// An external event has happened and may affect the state of this bloc
@toString
class _ListTagFileBlocExternalEvent extends ListTagFileBlocEvent {
const _ListTagFileBlocExternalEvent();
@override
String toString() => _$toString();
}
@toString
abstract class ListTagFileBlocState {
const ListTagFileBlocState(this.account, this.items);
@override
String toString() => _$toString();
final Account? account;
final List<File> items;
}
class ListTagFileBlocInit extends ListTagFileBlocState {
ListTagFileBlocInit() : super(null, const []);
}
class ListTagFileBlocLoading extends ListTagFileBlocState {
const ListTagFileBlocLoading(Account? account, List<File> items)
: super(account, items);
}
class ListTagFileBlocSuccess extends ListTagFileBlocState {
const ListTagFileBlocSuccess(Account? account, List<File> items)
: super(account, items);
}
@toString
class ListTagFileBlocFailure extends ListTagFileBlocState {
const ListTagFileBlocFailure(
Account? account, List<File> items, this.exception)
: super(account, items);
@override
String toString() => _$toString();
final Object exception;
}
/// The state of this bloc is inconsistent. This typically means that the data
/// may have been changed externally
class ListTagFileBlocInconsistent extends ListTagFileBlocState {
const ListTagFileBlocInconsistent(Account? account, List<File> items)
: super(account, items);
}
/// List all files associated with a specific tag
@npLog
class ListTagFileBloc extends Bloc<ListTagFileBlocEvent, ListTagFileBlocState> {
ListTagFileBloc(this._c)
: assert(require(_c)),
// assert(PopulatePerson.require(_c)),
super(ListTagFileBlocInit()) {
_fileRemovedEventListener.begin();
_filePropertyUpdatedEventListener.begin();
on<ListTagFileBlocEvent>(_onEvent);
}
static bool require(DiContainer c) =>
DiContainer.has(c, DiType.taggedFileRepo);
@override
close() {
_fileRemovedEventListener.end();
_filePropertyUpdatedEventListener.end();
return super.close();
}
Future<void> _onEvent(
ListTagFileBlocEvent event, Emitter<ListTagFileBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is ListTagFileBlocQuery) {
await _onEventQuery(event, emit);
} else if (event is _ListTagFileBlocExternalEvent) {
await _onExternalEvent(event, emit);
}
}
Future<void> _onEventQuery(
ListTagFileBlocQuery ev, Emitter<ListTagFileBlocState> emit) async {
try {
emit(ListTagFileBlocLoading(ev.account, state.items));
emit(ListTagFileBlocSuccess(ev.account, await _query(ev)));
} catch (e, stackTrace) {
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
emit(ListTagFileBlocFailure(ev.account, state.items, e));
}
}
Future<void> _onExternalEvent(_ListTagFileBlocExternalEvent ev,
Emitter<ListTagFileBlocState> emit) async {
emit(ListTagFileBlocInconsistent(state.account, state.items));
}
void _onFileRemovedEvent(FileRemovedEvent ev) {
if (state is ListTagFileBlocInit) {
// no data in this bloc, ignore
return;
}
if (_isFileOfInterest(ev.file)) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
}
}
void _onFilePropertyUpdatedEvent(FilePropertyUpdatedEvent ev) {
if (!ev.hasAnyProperties([
FilePropertyUpdatedEvent.propMetadata,
FilePropertyUpdatedEvent.propIsArchived,
FilePropertyUpdatedEvent.propOverrideDateTime,
FilePropertyUpdatedEvent.propFavorite,
])) {
// not interested
return;
}
if (state is ListTagFileBlocInit) {
// no data in this bloc, ignore
return;
}
if (!_isFileOfInterest(ev.file)) {
return;
}
if (ev.hasAnyProperties([
FilePropertyUpdatedEvent.propIsArchived,
FilePropertyUpdatedEvent.propOverrideDateTime,
FilePropertyUpdatedEvent.propFavorite,
])) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
} else {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 10),
maxPendingCount: 10,
);
}
}
Future<List<File>> _query(ListTagFileBlocQuery ev) async {
final files = <File>[];
for (final r in ev.account.roots) {
final dir = File(path: file_util.unstripPath(ev.account, r));
final taggedFiles =
await _c.taggedFileRepo.list(ev.account, dir, [ev.tag]);
files.addAll(await FindFile(_c)(
ev.account,
taggedFiles.map((e) => e.fileId).toList(),
onFileNotFound: (id) {
_log.warning("[_query] Missing file: $id");
},
));
}
return files.where((f) => file_util.isSupportedFormat(f)).toList();
}
bool _isFileOfInterest(File file) {
if (!file_util.isSupportedFormat(file)) {
return false;
}
for (final r in state.account?.roots ?? []) {
final dir = File(path: file_util.unstripPath(state.account!, r));
if (file_util.isUnderDir(file, dir)) {
return true;
}
}
return false;
}
final DiContainer _c;
late final _fileRemovedEventListener =
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
late final _filePropertyUpdatedEventListener =
AppEventListener<FilePropertyUpdatedEvent>(_onFilePropertyUpdatedEvent);
late final _refreshThrottler = Throttler(
onTriggered: (_) {
add(const _ListTagFileBlocExternalEvent());
},
logTag: "ListTagFileBloc.refresh",
);
}

View file

@ -1,47 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'list_tag_file.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$ListTagFileBlocNpLog on ListTagFileBloc {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("bloc.list_tag_file.ListTagFileBloc");
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$ListTagFileBlocQueryToString on ListTagFileBlocQuery {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "ListTagFileBlocQuery {account: $account, tag: $tag}";
}
}
extension _$_ListTagFileBlocExternalEventToString
on _ListTagFileBlocExternalEvent {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_ListTagFileBlocExternalEvent {}";
}
}
extension _$ListTagFileBlocStateToString on ListTagFileBlocState {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "${objectRuntimeType(this, "ListTagFileBlocState")} {account: $account, items: [length: ${items.length}]}";
}
}
extension _$ListTagFileBlocFailureToString on ListTagFileBlocFailure {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "ListTagFileBlocFailure {account: $account, items: [length: ${items.length}], exception: $exception}";
}
}

View file

@ -481,7 +481,7 @@ class ScanAccountDirBloc
return files;
}
bool _isFileOfInterest(File file) {
bool _isFileOfInterest(FileDescriptor file) {
if (!file_util.isSupportedFormat(file)) {
return false;
}

View file

@ -4,6 +4,7 @@ import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/search.dart';
import 'package:nc_photos/event/event.dart';
@ -198,7 +199,7 @@ class SearchBloc extends Bloc<SearchBlocEvent, SearchBlocState> {
Future<List<File>> _query(SearchBlocQuery ev) =>
Search(_c)(ev.account, ev.criteria);
bool _isFileOfInterest(File file) {
bool _isFileOfInterest(FileDescriptor file) {
if (!file_util.isSupportedFormat(file)) {
return false;
}

3
app/lib/bloc_util.dart Normal file
View file

@ -0,0 +1,3 @@
abstract class BlocTag {
String get tag;
}

View file

@ -0,0 +1,32 @@
import 'package:kiwi/kiwi.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/controller/collections_controller.dart';
import 'package:nc_photos/controller/server_controller.dart';
import 'package:nc_photos/di_container.dart';
class AccountController {
void setCurrentAccount(Account account) {
_account = account;
_collectionsController?.dispose();
_collectionsController = null;
_serverController = null;
}
Account get account => _account!;
CollectionsController get collectionsController =>
_collectionsController ??= CollectionsController(
KiwiContainer().resolve<DiContainer>(),
account: _account!,
serverController: serverController,
);
ServerController get serverController =>
_serverController ??= ServerController(
account: _account!,
);
Account? _account;
CollectionsController? _collectionsController;
ServerController? _serverController;
}

View file

@ -0,0 +1,348 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:copy_with/copy_with.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:mutex/mutex.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/adapter.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/collection_item/new_item.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/exception_event.dart';
import 'package:nc_photos/list_extension.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/rx_extension.dart';
import 'package:nc_photos/use_case/collection/add_file_to_collection.dart';
import 'package:nc_photos/use_case/collection/list_collection_item.dart';
import 'package:nc_photos/use_case/collection/remove_from_collection.dart';
import 'package:nc_photos/use_case/collection/update_collection_post_load.dart';
import 'package:nc_photos/use_case/remove.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:rxdart/rxdart.dart';
part 'collection_items_controller.g.dart';
@genCopyWith
class CollectionItemStreamData {
const CollectionItemStreamData({
required this.items,
required this.hasNext,
});
final List<CollectionItem> items;
/// If true, the results are intermediate values and may not represent the
/// latest state
final bool hasNext;
}
@npLog
class CollectionItemsController {
CollectionItemsController(
this._c, {
required this.account,
required this.collection,
required this.onCollectionUpdated,
}) {
_fileRemovedEventListener.begin();
}
/// Dispose this controller and release all internal resources
///
/// MUST be called
void dispose() {
_fileRemovedEventListener.end();
_dataStreamController.close();
}
/// Subscribe to collection items in [collection]
///
/// The returned stream will emit new list of items whenever there are changes
/// to the items (e.g., new item, removed item, etc)
///
/// There's no guarantee that the returned list is always sorted in some ways,
/// callers must sort it by themselves if the ordering is important
ValueStream<CollectionItemStreamData> get stream {
if (!_isDataStreamInited) {
_isDataStreamInited = true;
unawaited(_load());
}
return _dataStreamController.stream;
}
/// Peek the stream and return the current value
CollectionItemStreamData peekStream() => _dataStreamController.stream.value;
/// Add list of [files] to [collection]
Future<void> addFiles(List<FileDescriptor> files) async {
final isInited = _isDataStreamInited;
final List<FileDescriptor> toAdd;
if (isInited) {
toAdd = files
.where((a) => _dataStreamController.value.items
.whereType<CollectionFileItem>()
.every((b) => !a.compareServerIdentity(b.file)))
.toList();
_log.info("[addFiles] Adding ${toAdd.length} non duplicated files");
if (toAdd.isEmpty) {
return;
}
_dataStreamController.addWithValue((value) => value.copyWith(
items: [
...toAdd.map((f) => NewCollectionFileItem(f)),
...value.items,
],
));
} else {
toAdd = files;
_log.info("[addFiles] Adding ${toAdd.length} files");
if (toAdd.isEmpty) {
return;
}
}
ExceptionEvent? error;
final failed = <FileDescriptor>[];
await _mutex.protect(() async {
await AddFileToCollection(_c)(
account,
collection,
toAdd,
onError: (f, e, stackTrace) {
_log.severe("[addFiles] Exception: ${logFilename(f.strippedPath)}", e,
stackTrace);
error ??= ExceptionEvent(e, stackTrace);
failed.add(f);
},
onCollectionUpdated: (value) {
collection = value;
onCollectionUpdated(collection);
},
);
if (isInited) {
error
?.run((e) => _dataStreamController.addError(e.error, e.stackTrace));
var finalize = _dataStreamController.value.items.toList();
if (failed.isNotEmpty) {
// remove failed items
finalize.removeWhere((r) {
if (r is CollectionFileItem) {
return failed.any((f) => r.file.compareServerIdentity(f));
} else {
return false;
}
});
}
// convert intermediate items
finalize = (await finalize.asyncMap((e) async {
try {
if (e is NewCollectionFileItem) {
return await CollectionAdapter.of(_c, account, collection)
.adaptToNewItem(e);
} else {
return e;
}
} catch (e, stackTrace) {
_log.severe("[addFiles] Item not found in resulting collection: $e",
e, stackTrace);
return null;
}
}))
.whereNotNull()
.toList();
_dataStreamController.addWithValue((value) => value.copyWith(
items: finalize,
));
} else if (isInited != _isDataStreamInited) {
// stream loaded in between this op, reload
unawaited(_load());
}
});
error?.throwMe();
}
/// Remove list of [items] from [collection]
///
/// The items are compared with [identical], so it's required that all the
/// item instances come from the value stream
Future<void> removeItems(List<CollectionItem> items) async {
final isInited = _isDataStreamInited;
if (isInited) {
_dataStreamController.addWithValue((value) => value.copyWith(
items: value.items
.where((a) => !items.any((b) => identical(a, b)))
.toList(),
));
}
ExceptionEvent? error;
final failed = <CollectionItem>[];
await _mutex.protect(() async {
await RemoveFromCollection(_c)(
account,
collection,
items,
onError: (_, item, e, stackTrace) {
_log.severe("[removeItems] Exception: $item", e, stackTrace);
error ??= ExceptionEvent(e, stackTrace);
failed.add(item);
},
onCollectionUpdated: (value) {
collection = value;
onCollectionUpdated(collection);
},
);
if (isInited) {
error
?.run((e) => _dataStreamController.addError(e.error, e.stackTrace));
if (failed.isNotEmpty) {
_dataStreamController.addWithValue((value) => value.copyWith(
items: value.items + failed,
));
}
} else if (isInited != _isDataStreamInited) {
// stream loaded in between this op, reload
unawaited(_load());
}
});
error?.throwMe();
}
/// Delete list of [files] from your server
///
/// This is a temporary workaround and will be moved away
Future<void> deleteItems(List<FileDescriptor> files) async {
final isInited = _isDataStreamInited;
final List<FileDescriptor> toDelete;
List<CollectionFileItem>? toDeleteItem;
if (isInited) {
final groups = _dataStreamController.value.items.groupListsBy((i) {
if (i is CollectionFileItem) {
return !files.any((f) => i.file.compareServerIdentity(f));
} else {
return true;
}
});
final retain = groups[true] ?? const [];
toDeleteItem = groups[false]?.cast<CollectionFileItem>() ?? const [];
if (toDeleteItem.isEmpty) {
return;
}
_dataStreamController.addWithValue((value) => value.copyWith(
items: retain,
));
toDelete = toDeleteItem.map((e) => e.file).toList();
} else {
toDelete = files;
}
ExceptionEvent? error;
final failed = <CollectionItem>[];
await _mutex.protect(() async {
await Remove(_c)(
account,
toDelete,
onError: (i, f, e, stackTrace) {
_log.severe("[deleteItems] Exception: ${logFilename(f.strippedPath)}",
e, stackTrace);
error ??= ExceptionEvent(e, stackTrace);
if (isInited) {
failed.add(toDeleteItem![i]);
}
},
);
if (isInited) {
error
?.run((e) => _dataStreamController.addError(e.error, e.stackTrace));
if (failed.isNotEmpty) {
_dataStreamController.addWithValue((value) => value.copyWith(
items: value.items + failed,
));
}
} else if (isInited != _isDataStreamInited) {
// stream loaded in between this op, reload
unawaited(_load());
}
});
error?.throwMe();
}
/// Replace items in the stream, for internal use only
void forceReplaceItems(List<CollectionItem> items) {
_dataStreamController.addWithValue((v) => v.copyWith(items: items));
}
Future<void> _load() async {
try {
List<CollectionItem>? items;
await for (final r in ListCollectionItem(_c)(account, collection)) {
items = r;
_dataStreamController.add(CollectionItemStreamData(
items: r,
hasNext: true,
));
}
if (items != null) {
_dataStreamController.add(CollectionItemStreamData(
items: items,
hasNext: false,
));
final newCollection =
await UpdateCollectionPostLoad(_c)(account, collection, items);
if (newCollection != null) {
onCollectionUpdated(newCollection);
}
}
} catch (e, stackTrace) {
_dataStreamController
..addError(e, stackTrace)
..addWithValue((v) => v.copyWith(hasNext: false));
}
}
void _onFileRemovedEvent(FileRemovedEvent ev) {
// if (account != ev.account) {
// return;
// }
// final newItems = _dataStreamController.value.items.where((e) {
// if (e is CollectionFileItem) {
// return !e.file.compareServerIdentity(ev.file);
// } else {
// return true;
// }
// }).toList();
// if (newItems.length != _dataStreamController.value.items.length) {
// // item of interest
// _dataStreamController.addWithValue((value) => value.copyWith(
// items: newItems,
// ));
// }
}
final DiContainer _c;
final Account account;
Collection collection;
ValueChanged<Collection> onCollectionUpdated;
var _isDataStreamInited = false;
final _dataStreamController = BehaviorSubject.seeded(
const CollectionItemStreamData(
items: [],
hasNext: true,
),
);
late final _fileRemovedEventListener =
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
final _mutex = Mutex();
}

View file

@ -0,0 +1,49 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'collection_items_controller.dart';
// **************************************************************************
// CopyWithLintRuleGenerator
// **************************************************************************
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class $CollectionItemStreamDataCopyWithWorker {
CollectionItemStreamData call({List<CollectionItem>? items, bool? hasNext});
}
class _$CollectionItemStreamDataCopyWithWorkerImpl
implements $CollectionItemStreamDataCopyWithWorker {
_$CollectionItemStreamDataCopyWithWorkerImpl(this.that);
@override
CollectionItemStreamData call({dynamic items, dynamic hasNext}) {
return CollectionItemStreamData(
items: items as List<CollectionItem>? ?? that.items,
hasNext: hasNext as bool? ?? that.hasNext);
}
final CollectionItemStreamData that;
}
extension $CollectionItemStreamDataCopyWith on CollectionItemStreamData {
$CollectionItemStreamDataCopyWithWorker get copyWith => _$copyWith;
$CollectionItemStreamDataCopyWithWorker get _$copyWith =>
_$CollectionItemStreamDataCopyWithWorkerImpl(this);
}
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$CollectionItemsControllerNpLog on CollectionItemsController {
// ignore: unused_element
Logger get _log => log;
static final log = Logger(
"controller.collection_items_controller.CollectionItemsController");
}

View file

@ -0,0 +1,379 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:copy_with/copy_with.dart';
import 'package:logging/logging.dart';
import 'package:mutex/mutex.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/controller/collection_items_controller.dart';
import 'package:nc_photos/controller/server_controller.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/util.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/sharee.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/rx_extension.dart';
import 'package:nc_photos/use_case/collection/create_collection.dart';
import 'package:nc_photos/use_case/collection/edit_collection.dart';
import 'package:nc_photos/use_case/collection/import_pending_shared_collection.dart';
import 'package:nc_photos/use_case/collection/list_collection.dart';
import 'package:nc_photos/use_case/collection/remove_collections.dart';
import 'package:nc_photos/use_case/collection/share_collection.dart';
import 'package:nc_photos/use_case/collection/unshare_collection.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/type.dart';
import 'package:rxdart/rxdart.dart';
part 'collections_controller.g.dart';
@genCopyWith
class CollectionStreamData {
const CollectionStreamData({
required this.collection,
required this.controller,
});
final Collection collection;
final CollectionItemsController controller;
}
@genCopyWith
class CollectionStreamEvent {
const CollectionStreamEvent({
required this.data,
required this.hasNext,
});
CollectionItemsController itemsControllerByCollection(Collection collection) {
final i = data.indexWhere((d) => collection.compareIdentity(d.collection));
return data[i].controller;
}
final List<CollectionStreamData> data;
/// If true, the results are intermediate values and may not represent the
/// latest state
final bool hasNext;
}
@npLog
class CollectionsController {
CollectionsController(
this._c, {
required this.account,
required this.serverController,
});
void dispose() {
_dataStreamController.close();
for (final c in _itemControllers.values) {
c.dispose();
}
}
/// Return a stream of collections associated with [account]
///
/// The returned stream will emit new list of collections whenever there are
/// changes to the collections (e.g., new collection, removed collection, etc)
///
/// There's no guarantee that the returned list is always sorted in some ways,
/// callers must sort it by themselves if the ordering is important
ValueStream<CollectionStreamEvent> get stream {
if (!_isDataStreamInited) {
_isDataStreamInited = true;
unawaited(_load());
}
return _dataStreamController.stream;
}
/// Peek the stream and return the current value
CollectionStreamEvent peekStream() => _dataStreamController.stream.value;
/// Reload the data
///
/// The data will be loaded automatically when the stream is first listened so
/// it's not necessary to call this method for that purpose
Future<void> reload() async {
var results = <Collection>[];
final completer = Completer();
ListCollection(_c, serverController: serverController)(account).listen(
(c) {
results = c;
},
onError: _dataStreamController.addError,
onDone: () => completer.complete(),
);
await completer.future;
_dataStreamController.add(CollectionStreamEvent(
data: _prepareDataFor(results),
hasNext: false,
));
}
Future<Collection> createNew(Collection collection) async {
// we can't simply add the collection argument to the stream because
// the collection may not be a complete workable instance
final created = await CreateCollection(_c)(account, collection);
_dataStreamController.addWithValue((v) => v.copyWith(
data: _prepareDataFor([
created,
...v.data.map((e) => e.collection),
]),
));
return created;
}
/// Remove [collections] and return the removed count
///
/// If [onError] is not null, you'll get notified about the errors. The future
/// will always complete normally
Future<int> remove(
List<Collection> collections, {
ErrorWithValueHandler<Collection>? onError,
}) async {
final newData = _dataStreamController.value.data.toList();
final toBeRemoved = <CollectionStreamData>[];
var failedCount = 0;
for (final c in collections) {
final i = newData.indexWhere((d) => c.compareIdentity(d.collection));
if (i == -1) {
_log.warning("[remove] Collection not found: $c");
} else {
toBeRemoved.add(newData.removeAt(i));
}
}
_dataStreamController.addWithValue((v) => v.copyWith(
data: newData,
));
final restore = <CollectionStreamData>[];
await _mutex.protect(() async {
await RemoveCollections(_c)(
account,
collections,
onError: (c, e, stackTrace) {
_log.severe(
"[remove] Failed while RemoveCollections: $c", e, stackTrace);
final i =
toBeRemoved.indexWhere((d) => c.compareIdentity(d.collection));
if (i != -1) {
restore.add(toBeRemoved.removeAt(i));
}
++failedCount;
onError?.call(c, e, stackTrace);
},
);
});
toBeRemoved
.map((d) => _CollectionKey(d.collection))
.forEach(_itemControllers.remove);
if (restore.isNotEmpty) {
_log.severe("[remove] Restoring ${restore.length} collections");
_dataStreamController.addWithValue((v) => v.copyWith(
data: [
...restore,
...v.data,
],
));
}
return collections.length - failedCount;
}
/// See [EditCollection]
Future<void> edit(
Collection collection, {
String? name,
List<CollectionItem>? items,
CollectionItemSort? itemSort,
OrNull<FileDescriptor>? cover,
}) async {
try {
final c = await _mutex.protect(() async {
final found = _dataStreamController.value.data.firstWhereOrNull(
(ev) => ev.collection.compareIdentity(collection));
final item = found?.controller.peekStream();
return await EditCollection(_c)(
account,
collection,
name: name,
items: items,
itemSort: itemSort,
cover: cover,
knownItems: (item?.items.isEmpty ?? true) ? null : item!.items,
);
});
_updateCollection(c, items);
} catch (e, stackTrace) {
_dataStreamController.addError(e, stackTrace);
}
}
Future<void> share(Collection collection, Sharee sharee) async {
try {
Collection? newCollection;
final result = await _mutex.protect(() async {
return await ShareCollection(_c)(
account,
collection,
sharee,
onCollectionUpdated: (c) {
newCollection = c;
},
);
});
if (newCollection != null) {
_updateCollection(newCollection!);
}
if (result == CollectionShareResult.partial) {
_dataStreamController
.addError(CollectionPartialShareException(sharee.shareWith.raw));
}
} catch (e, stackTrace) {
_dataStreamController.addError(e, stackTrace);
}
}
Future<void> unshare(Collection collection, CollectionShare share) async {
try {
Collection? newCollection;
final result = await _mutex.protect(() async {
return await UnshareCollection(_c)(
account,
collection,
share.userId,
onCollectionUpdated: (c) {
newCollection = c;
},
);
});
if (newCollection != null) {
_updateCollection(newCollection!);
}
if (result == CollectionShareResult.partial) {
_dataStreamController
.addError(CollectionPartialUnshareException(share.username));
}
} catch (e, stackTrace) {
_dataStreamController.addError(e, stackTrace);
}
}
/// See [ImportPendingSharedCollection]
Future<Collection?> importPendingSharedCollection(
Collection collection) async {
try {
final newCollection =
await ImportPendingSharedCollection(_c)(account, collection);
_dataStreamController.addWithValue((v) => v.copyWith(
data: _prepareDataFor([
newCollection,
...v.data.map((e) => e.collection),
]),
));
return newCollection;
} catch (e, stackTrace) {
_dataStreamController.addError(e, stackTrace);
return null;
}
}
Future<void> _load() async {
var lastData = const CollectionStreamEvent(
data: [],
hasNext: false,
);
final completer = Completer();
ListCollection(_c, serverController: serverController)(account).listen(
(c) {
lastData = CollectionStreamEvent(
data: _prepareDataFor(c),
hasNext: true,
);
_dataStreamController.add(lastData);
},
onError: _dataStreamController.addError,
onDone: () => completer.complete(),
);
await completer.future;
_dataStreamController.add(lastData.copyWith(hasNext: false));
}
List<CollectionStreamData> _prepareDataFor(List<Collection> collections) {
final data = <CollectionStreamData>[];
final keys = <_CollectionKey>[];
for (final c in collections) {
final k = _CollectionKey(c);
_itemControllers[k] ??= CollectionItemsController(
_c,
account: account,
collection: k.collection,
onCollectionUpdated: _updateCollection,
);
data.add(CollectionStreamData(
collection: c,
controller: _itemControllers[k]!,
));
keys.add(k);
}
final remove =
_itemControllers.keys.where((k) => !keys.contains(k)).toList();
for (final k in remove) {
_itemControllers[k]?.dispose();
_itemControllers.remove(k);
}
return data;
}
void _updateCollection(Collection collection, [List<CollectionItem>? items]) {
_log.info("[_updateCollection] Updating collection: $collection");
_dataStreamController.addWithValue((v) => v.copyWith(
data: v.data.map((d) {
if (d.collection.compareIdentity(collection)) {
if (items != null) {
d.controller.forceReplaceItems(items);
}
return d.copyWith(collection: collection);
} else {
return d;
}
}).toList(),
));
}
final DiContainer _c;
final Account account;
final ServerController serverController;
var _isDataStreamInited = false;
final _dataStreamController = BehaviorSubject.seeded(
const CollectionStreamEvent(
data: [],
hasNext: true,
),
);
final _itemControllers = <_CollectionKey, CollectionItemsController>{};
final _mutex = Mutex();
}
class _CollectionKey {
const _CollectionKey(this.collection);
@override
bool operator ==(Object other) {
return other is _CollectionKey &&
collection.compareIdentity(other.collection);
}
@override
int get hashCode => collection.identityHashCode;
final Collection collection;
}

View file

@ -0,0 +1,75 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'collections_controller.dart';
// **************************************************************************
// CopyWithLintRuleGenerator
// **************************************************************************
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class $CollectionStreamDataCopyWithWorker {
CollectionStreamData call(
{Collection? collection, CollectionItemsController? controller});
}
class _$CollectionStreamDataCopyWithWorkerImpl
implements $CollectionStreamDataCopyWithWorker {
_$CollectionStreamDataCopyWithWorkerImpl(this.that);
@override
CollectionStreamData call({dynamic collection, dynamic controller}) {
return CollectionStreamData(
collection: collection as Collection? ?? that.collection,
controller:
controller as CollectionItemsController? ?? that.controller);
}
final CollectionStreamData that;
}
extension $CollectionStreamDataCopyWith on CollectionStreamData {
$CollectionStreamDataCopyWithWorker get copyWith => _$copyWith;
$CollectionStreamDataCopyWithWorker get _$copyWith =>
_$CollectionStreamDataCopyWithWorkerImpl(this);
}
abstract class $CollectionStreamEventCopyWithWorker {
CollectionStreamEvent call({List<CollectionStreamData>? data, bool? hasNext});
}
class _$CollectionStreamEventCopyWithWorkerImpl
implements $CollectionStreamEventCopyWithWorker {
_$CollectionStreamEventCopyWithWorkerImpl(this.that);
@override
CollectionStreamEvent call({dynamic data, dynamic hasNext}) {
return CollectionStreamEvent(
data: data as List<CollectionStreamData>? ?? that.data,
hasNext: hasNext as bool? ?? that.hasNext);
}
final CollectionStreamEvent that;
}
extension $CollectionStreamEventCopyWith on CollectionStreamEvent {
$CollectionStreamEventCopyWithWorker get copyWith => _$copyWith;
$CollectionStreamEventCopyWithWorker get _$copyWith =>
_$CollectionStreamEventCopyWithWorkerImpl(this);
}
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$CollectionsControllerNpLog on CollectionsController {
// ignore: unused_element
Logger get _log => log;
static final log =
Logger("controller.collections_controller.CollectionsController");
}

View file

@ -0,0 +1,54 @@
import 'package:logging/logging.dart';
import 'package:nc_photos/di_container.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:rxdart/rxdart.dart';
part 'pref_controller.g.dart';
@npLog
class PrefController {
PrefController(this._c);
ValueStream<int> get albumBrowserZoomLevel =>
_albumBrowserZoomLevelController.stream;
Future<void> setAlbumBrowserZoomLevel(int value) async {
final backup = _albumBrowserZoomLevelController.value;
_albumBrowserZoomLevelController.add(value);
try {
if (!await _c.pref.setAlbumBrowserZoomLevel(value)) {
throw StateError("Unknown error");
}
} catch (e, stackTrace) {
_log.severe("[setAlbumBrowserZoomLevel] Failed setting preference", e,
stackTrace);
_albumBrowserZoomLevelController
..addError(e, stackTrace)
..add(backup);
}
}
ValueStream<int> get homeAlbumsSort => _homeAlbumsSortController.stream;
Future<void> setHomeAlbumsSort(int value) async {
final backup = _homeAlbumsSortController.value;
_homeAlbumsSortController.add(value);
try {
if (!await _c.pref.setHomeAlbumsSort(value)) {
throw StateError("Unknown error");
}
} catch (e, stackTrace) {
_log.severe(
"[setHomeAlbumsSort] Failed setting preference", e, stackTrace);
_homeAlbumsSortController
..addError(e, stackTrace)
..add(backup);
}
}
final DiContainer _c;
late final _albumBrowserZoomLevelController =
BehaviorSubject.seeded(_c.pref.getAlbumBrowserZoomLevelOr(0));
late final _homeAlbumsSortController =
BehaviorSubject.seeded(_c.pref.getHomeAlbumsSortOr(0));
}

View file

@ -1,14 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'album_picker.dart';
part of 'pref_controller.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$_AlbumPickerStateNpLog on _AlbumPickerState {
extension _$PrefControllerNpLog on PrefController {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("widget.album_picker._AlbumPickerState");
static final log = Logger("controller.pref_controller.PrefController");
}

View file

@ -0,0 +1,61 @@
import 'dart:async';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/entity_converter.dart';
import 'package:nc_photos/entity/server_status.dart';
import 'package:nc_photos/np_api_util.dart';
import 'package:np_api/np_api.dart' as api;
import 'package:np_codegen/np_codegen.dart';
import 'package:rxdart/rxdart.dart';
part 'server_controller.g.dart';
enum ServerFeature {
ncAlbum,
}
@npLog
class ServerController {
ServerController({
required this.account,
});
ValueStream<ServerStatus> get status {
if (!_statusStreamContorller.hasValue) {
unawaited(_load());
}
return _statusStreamContorller.stream;
}
bool isSupported(ServerFeature feature) {
switch (feature) {
case ServerFeature.ncAlbum:
return !_statusStreamContorller.hasValue ||
_statusStreamContorller.value.majorVersion >= 25;
}
}
Future<void> _load() => _getStatus();
Future<void> _getStatus() async {
try {
final response = await ApiUtil.fromAccount(account).status().get();
if (!response.isGood) {
_log.severe("[_getStatus] Failed requesting server: $response");
return;
}
final apiStatus = await api.StatusParser().parse(response.body);
final status = ApiStatusConverter.fromApi(apiStatus);
_log.info("[_getStatus] Server status: $status");
_statusStreamContorller.add(status);
} catch (e, stackTrace) {
_log.severe("[_getStatus] Failed while get", e, stackTrace);
return;
}
}
final Account account;
final _statusStreamContorller = BehaviorSubject<ServerStatus>();
}

View file

@ -0,0 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'server_controller.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$ServerControllerNpLog on ServerController {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("controller.server_controller.ServerController");
}

View file

@ -1,8 +1,10 @@
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/repo2.dart';
import 'package:nc_photos/entity/face.dart';
import 'package:nc_photos/entity/favorite.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/local_file.dart';
import 'package:nc_photos/entity/nc_album/repo.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/entity/search.dart';
import 'package:nc_photos/entity/share.dart';
@ -16,7 +18,11 @@ import 'package:nc_photos/touch_manager.dart';
enum DiType {
albumRepo,
albumRepoRemote,
albumRepoLocal,
albumRepo2,
albumRepo2Remote,
albumRepo2Local,
faceRepo,
fileRepo,
fileRepoRemote,
@ -33,6 +39,9 @@ enum DiType {
taggedFileRepo,
localFileRepo,
searchRepo,
ncAlbumRepo,
ncAlbumRepoRemote,
ncAlbumRepoLocal,
pref,
sqliteDb,
touchManager,
@ -41,7 +50,11 @@ enum DiType {
class DiContainer {
DiContainer({
AlbumRepo? albumRepo,
AlbumRepo? albumRepoRemote,
AlbumRepo? albumRepoLocal,
AlbumRepo2? albumRepo2,
AlbumRepo2? albumRepo2Remote,
AlbumRepo2? albumRepo2Local,
FaceRepo? faceRepo,
FileRepo? fileRepo,
FileRepo? fileRepoRemote,
@ -58,11 +71,18 @@ class DiContainer {
TaggedFileRepo? taggedFileRepo,
LocalFileRepo? localFileRepo,
SearchRepo? searchRepo,
NcAlbumRepo? ncAlbumRepo,
NcAlbumRepo? ncAlbumRepoRemote,
NcAlbumRepo? ncAlbumRepoLocal,
Pref? pref,
sql.SqliteDb? sqliteDb,
TouchManager? touchManager,
}) : _albumRepo = albumRepo,
_albumRepoRemote = albumRepoRemote,
_albumRepoLocal = albumRepoLocal,
_albumRepo2 = albumRepo2,
_albumRepo2Remote = albumRepo2Remote,
_albumRepo2Local = albumRepo2Local,
_faceRepo = faceRepo,
_fileRepo = fileRepo,
_fileRepoRemote = fileRepoRemote,
@ -79,6 +99,9 @@ class DiContainer {
_taggedFileRepo = taggedFileRepo,
_localFileRepo = localFileRepo,
_searchRepo = searchRepo,
_ncAlbumRepo = ncAlbumRepo,
_ncAlbumRepoRemote = ncAlbumRepoRemote,
_ncAlbumRepoLocal = ncAlbumRepoLocal,
_pref = pref,
_sqliteDb = sqliteDb,
_touchManager = touchManager;
@ -89,8 +112,16 @@ class DiContainer {
switch (type) {
case DiType.albumRepo:
return contianer._albumRepo != null;
case DiType.albumRepoRemote:
return contianer._albumRepoRemote != null;
case DiType.albumRepoLocal:
return contianer._albumRepoLocal != null;
case DiType.albumRepo2:
return contianer._albumRepo2 != null;
case DiType.albumRepo2Remote:
return contianer._albumRepo2Remote != null;
case DiType.albumRepo2Local:
return contianer._albumRepo2Local != null;
case DiType.faceRepo:
return contianer._faceRepo != null;
case DiType.fileRepo:
@ -123,6 +154,12 @@ class DiContainer {
return contianer._localFileRepo != null;
case DiType.searchRepo:
return contianer._searchRepo != null;
case DiType.ncAlbumRepo:
return contianer._ncAlbumRepo != null;
case DiType.ncAlbumRepoRemote:
return contianer._ncAlbumRepoRemote != null;
case DiType.ncAlbumRepoLocal:
return contianer._ncAlbumRepoLocal != null;
case DiType.pref:
return contianer._pref != null;
case DiType.sqliteDb:
@ -134,6 +171,7 @@ class DiContainer {
DiContainer copyWith({
OrNull<AlbumRepo>? albumRepo,
OrNull<AlbumRepo2>? albumRepo2,
OrNull<FaceRepo>? faceRepo,
OrNull<FileRepo>? fileRepo,
OrNull<PersonRepo>? personRepo,
@ -144,12 +182,14 @@ class DiContainer {
OrNull<TaggedFileRepo>? taggedFileRepo,
OrNull<LocalFileRepo>? localFileRepo,
OrNull<SearchRepo>? searchRepo,
OrNull<NcAlbumRepo>? ncAlbumRepo,
OrNull<Pref>? pref,
OrNull<sql.SqliteDb>? sqliteDb,
OrNull<TouchManager>? touchManager,
}) {
return DiContainer(
albumRepo: albumRepo == null ? _albumRepo : albumRepo.obj,
albumRepo2: albumRepo2 == null ? _albumRepo2 : albumRepo2.obj,
faceRepo: faceRepo == null ? _faceRepo : faceRepo.obj,
fileRepo: fileRepo == null ? _fileRepo : fileRepo.obj,
personRepo: personRepo == null ? _personRepo : personRepo.obj,
@ -161,6 +201,7 @@ class DiContainer {
taggedFileRepo == null ? _taggedFileRepo : taggedFileRepo.obj,
localFileRepo: localFileRepo == null ? _localFileRepo : localFileRepo.obj,
searchRepo: searchRepo == null ? _searchRepo : searchRepo.obj,
ncAlbumRepo: ncAlbumRepo == null ? _ncAlbumRepo : ncAlbumRepo.obj,
pref: pref == null ? _pref : pref.obj,
sqliteDb: sqliteDb == null ? _sqliteDb : sqliteDb.obj,
touchManager: touchManager == null ? _touchManager : touchManager.obj,
@ -168,7 +209,11 @@ class DiContainer {
}
AlbumRepo get albumRepo => _albumRepo!;
AlbumRepo get albumRepoRemote => _albumRepoRemote!;
AlbumRepo get albumRepoLocal => _albumRepoLocal!;
AlbumRepo2 get albumRepo2 => _albumRepo2!;
AlbumRepo2 get albumRepo2Remote => _albumRepo2Remote!;
AlbumRepo2 get albumRepo2Local => _albumRepo2Local!;
FaceRepo get faceRepo => _faceRepo!;
FileRepo get fileRepo => _fileRepo!;
FileRepo get fileRepoRemote => _fileRepoRemote!;
@ -185,6 +230,9 @@ class DiContainer {
TaggedFileRepo get taggedFileRepo => _taggedFileRepo!;
LocalFileRepo get localFileRepo => _localFileRepo!;
SearchRepo get searchRepo => _searchRepo!;
NcAlbumRepo get ncAlbumRepo => _ncAlbumRepo!;
NcAlbumRepo get ncAlbumRepoRemote => _ncAlbumRepoRemote!;
NcAlbumRepo get ncAlbumRepoLocal => _ncAlbumRepoLocal!;
TouchManager get touchManager => _touchManager!;
sql.SqliteDb get sqliteDb => _sqliteDb!;
@ -195,11 +243,31 @@ class DiContainer {
_albumRepo = v;
}
set albumRepoRemote(AlbumRepo v) {
assert(_albumRepoRemote == null);
_albumRepoRemote = v;
}
set albumRepoLocal(AlbumRepo v) {
assert(_albumRepoLocal == null);
_albumRepoLocal = v;
}
set albumRepo2(AlbumRepo2 v) {
assert(_albumRepo2 == null);
_albumRepo2 = v;
}
set albumRepo2Remote(AlbumRepo2 v) {
assert(_albumRepo2Remote == null);
_albumRepo2Remote = v;
}
set albumRepo2Local(AlbumRepo2 v) {
assert(_albumRepo2Local == null);
_albumRepo2Local = v;
}
set faceRepo(FaceRepo v) {
assert(_faceRepo == null);
_faceRepo = v;
@ -280,6 +348,21 @@ class DiContainer {
_searchRepo = v;
}
set ncAlbumRepo(NcAlbumRepo v) {
assert(_ncAlbumRepo == null);
_ncAlbumRepo = v;
}
set ncAlbumRepoRemote(NcAlbumRepo v) {
assert(_ncAlbumRepoRemote == null);
_ncAlbumRepoRemote = v;
}
set ncAlbumRepoLocal(NcAlbumRepo v) {
assert(_ncAlbumRepoLocal == null);
_ncAlbumRepoLocal = v;
}
set touchManager(TouchManager v) {
assert(_touchManager == null);
_touchManager = v;
@ -296,9 +379,13 @@ class DiContainer {
}
AlbumRepo? _albumRepo;
AlbumRepo? _albumRepoRemote;
// Explicitly request a AlbumRepo backed by local source
AlbumRepo? _albumRepoLocal;
FaceRepo? _faceRepo;
AlbumRepo2? _albumRepo2;
AlbumRepo2? _albumRepo2Remote;
AlbumRepo2? _albumRepo2Local;
FileRepo? _fileRepo;
// Explicitly request a FileRepo backed by remote source
FileRepo? _fileRepoRemote;
@ -316,6 +403,9 @@ class DiContainer {
TaggedFileRepo? _taggedFileRepo;
LocalFileRepo? _localFileRepo;
SearchRepo? _searchRepo;
NcAlbumRepo? _ncAlbumRepo;
NcAlbumRepo? _ncAlbumRepoRemote;
NcAlbumRepo? _ncAlbumRepoLocal;
TouchManager? _touchManager;
sql.SqliteDb? _sqliteDb;
@ -323,14 +413,28 @@ class DiContainer {
}
extension DiContainerExtension on DiContainer {
/// Uses remote repo if available
///
/// Notice that not all repo support this
DiContainer withRemoteRepo() => copyWith(
albumRepo: OrNull(albumRepoRemote),
albumRepo2: OrNull(albumRepo2Remote),
fileRepo: OrNull(fileRepoRemote),
personRepo: OrNull(personRepoRemote),
tagRepo: OrNull(tagRepoRemote),
ncAlbumRepo: OrNull(ncAlbumRepoRemote),
);
/// Uses local repo if available
///
/// Notice that not all repo support this
DiContainer withLocalRepo() => copyWith(
albumRepo: OrNull(albumRepoLocal),
albumRepo2: OrNull(albumRepo2Local),
fileRepo: OrNull(fileRepoLocal),
personRepo: OrNull(personRepoLocal),
tagRepo: OrNull(tagRepoLocal),
ncAlbumRepo: OrNull(ncAlbumRepoLocal),
);
DiContainer withLocalAlbumRepo() =>

View file

@ -46,49 +46,56 @@ class Album with EquatableMixin {
final jsonVersion = json["version"];
JsonObj? result = json;
if (jsonVersion < 2) {
result = upgraderFactory?.buildV1()?.call(result);
result = upgraderFactory?.buildV1()?.doJson(result);
if (result == null) {
_log.info("[fromJson] Version $jsonVersion not compatible");
return null;
}
}
if (jsonVersion < 3) {
result = upgraderFactory?.buildV2()?.call(result);
result = upgraderFactory?.buildV2()?.doJson(result);
if (result == null) {
_log.info("[fromJson] Version $jsonVersion not compatible");
return null;
}
}
if (jsonVersion < 4) {
result = upgraderFactory?.buildV3()?.call(result);
result = upgraderFactory?.buildV3()?.doJson(result);
if (result == null) {
_log.info("[fromJson] Version $jsonVersion not compatible");
return null;
}
}
if (jsonVersion < 5) {
result = upgraderFactory?.buildV4()?.call(result);
result = upgraderFactory?.buildV4()?.doJson(result);
if (result == null) {
_log.info("[fromJson] Version $jsonVersion not compatible");
return null;
}
}
if (jsonVersion < 6) {
result = upgraderFactory?.buildV5()?.call(result);
result = upgraderFactory?.buildV5()?.doJson(result);
if (result == null) {
_log.info("[fromJson] Version $jsonVersion not compatible");
return null;
}
}
if (jsonVersion < 7) {
result = upgraderFactory?.buildV6()?.call(result);
result = upgraderFactory?.buildV6()?.doJson(result);
if (result == null) {
_log.info("[fromJson] Version $jsonVersion not compatible");
return null;
}
}
if (jsonVersion < 8) {
result = upgraderFactory?.buildV7()?.call(result);
result = upgraderFactory?.buildV7()?.doJson(result);
if (result == null) {
_log.info("[fromJson] Version $jsonVersion not compatible");
return null;
}
}
if (jsonVersion < 9) {
result = upgraderFactory?.buildV8()?.doJson(result);
if (result == null) {
_log.info("[fromJson] Version $jsonVersion not compatible");
return null;
@ -217,7 +224,7 @@ class Album with EquatableMixin {
final int savedVersion;
/// versioning of this class, use to upgrade old persisted album
static const version = 8;
static const version = 9;
static final _log = _$AlbumNpLog.log;
}

View file

@ -26,9 +26,6 @@ abstract class AlbumCoverProvider with EquatableMixin {
case AlbumManualCoverProvider._type:
return AlbumManualCoverProvider.fromJson(
content.cast<String, dynamic>());
case AlbumMemoryCoverProvider._type:
return AlbumMemoryCoverProvider.fromJson(
content.cast<String, dynamic>());
default:
_log.shout("[fromJson] Unknown type: $type");
throw ArgumentError.value(type, "type");
@ -65,7 +62,7 @@ abstract class AlbumCoverProvider with EquatableMixin {
/// Cover selected automatically by us
@toString
class AlbumAutoCoverProvider extends AlbumCoverProvider {
AlbumAutoCoverProvider({
const AlbumAutoCoverProvider({
this.coverFile,
});
@ -73,48 +70,48 @@ class AlbumAutoCoverProvider extends AlbumCoverProvider {
return AlbumAutoCoverProvider(
coverFile: json["coverFile"] == null
? null
: File.fromJson(json["coverFile"].cast<String, dynamic>()),
: FileDescriptor.fromJson(json["coverFile"].cast<String, dynamic>()),
);
}
static FileDescriptor? getCoverByItems(List<AlbumItem> items) {
return items
.whereType<AlbumFileItem>()
.map((e) => e.file)
.where((element) =>
file_util.isSupportedFormat(element) &&
(element.hasPreview ?? false) &&
element.fileId != null)
.sorted(compareFileDateTimeDescending)
.firstOrNull;
}
@override
String toString() => _$toString();
@override
getCover(Album album) {
if (coverFile == null) {
try {
FileDescriptor? getCover(Album album) {
if (coverFile == null && album.provider is AlbumStaticProvider) {
// use the latest file as cover
return AlbumStaticProvider.of(album)
.items
.whereType<AlbumFileItem>()
.map((e) => e.file)
.where((element) =>
file_util.isSupportedFormat(element) &&
(element.hasPreview ?? false))
.sorted(compareFileDateTimeDescending)
.first;
} catch (_) {
return null;
}
return getCoverByItems(AlbumStaticProvider.of(album).items);
} else {
return coverFile;
}
}
@override
get props => [
List<Object?> get props => [
coverFile,
];
@override
_toContentJson() {
JsonObj _toContentJson() {
return {
if (coverFile != null) "coverFile": coverFile!.toJson(),
if (coverFile != null) "coverFile": coverFile!.toFdJson(),
};
}
final File? coverFile;
final FileDescriptor? coverFile;
static const _type = "auto";
}
@ -122,48 +119,12 @@ class AlbumAutoCoverProvider extends AlbumCoverProvider {
/// Cover picked by user
@toString
class AlbumManualCoverProvider extends AlbumCoverProvider {
AlbumManualCoverProvider({
const AlbumManualCoverProvider({
required this.coverFile,
});
factory AlbumManualCoverProvider.fromJson(JsonObj json) {
return AlbumManualCoverProvider(
coverFile: File.fromJson(json["coverFile"].cast<String, dynamic>()),
);
}
@override
String toString() => _$toString();
@override
getCover(Album album) => coverFile;
@override
get props => [
coverFile,
];
@override
_toContentJson() {
return {
"coverFile": coverFile.toJson(),
};
}
final File coverFile;
static const _type = "manual";
}
/// Cover selected when building a Memory album
@toString
class AlbumMemoryCoverProvider extends AlbumCoverProvider {
AlbumMemoryCoverProvider({
required this.coverFile,
});
factory AlbumMemoryCoverProvider.fromJson(JsonObj json) {
return AlbumMemoryCoverProvider(
coverFile:
FileDescriptor.fromJson(json["coverFile"].cast<String, dynamic>()),
);
@ -173,19 +134,21 @@ class AlbumMemoryCoverProvider extends AlbumCoverProvider {
String toString() => _$toString();
@override
getCover(Album album) => coverFile;
FileDescriptor? getCover(Album album) => coverFile;
@override
get props => [
List<Object?> get props => [
coverFile,
];
@override
_toContentJson() => {
"coverFile": coverFile.toJson(),
JsonObj _toContentJson() {
return {
"coverFile": coverFile.toFdJson(),
};
}
final FileDescriptor coverFile;
static const _type = "memory";
static const _type = "manual";
}

View file

@ -20,20 +20,13 @@ extension _$AlbumCoverProviderNpLog on AlbumCoverProvider {
extension _$AlbumAutoCoverProviderToString on AlbumAutoCoverProvider {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "AlbumAutoCoverProvider {coverFile: ${coverFile == null ? null : "${coverFile!.path}"}}";
return "AlbumAutoCoverProvider {coverFile: ${coverFile == null ? null : "${coverFile!.fdPath}"}}";
}
}
extension _$AlbumManualCoverProviderToString on AlbumManualCoverProvider {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "AlbumManualCoverProvider {coverFile: ${coverFile.path}}";
}
}
extension _$AlbumMemoryCoverProviderToString on AlbumMemoryCoverProvider {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "AlbumMemoryCoverProvider {coverFile: ${coverFile.fdPath}}";
return "AlbumManualCoverProvider {coverFile: ${coverFile.fdPath}}";
}
}

View file

@ -1,106 +1,76 @@
import 'dart:convert';
import 'dart:math';
import 'package:clock/clock.dart';
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/album/data_source2.dart';
import 'package:nc_photos/entity/album/repo2.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/sqlite/database.dart' as sql;
import 'package:nc_photos/entity/sqlite/type_converter.dart';
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';
import 'package:np_codegen/np_codegen.dart';
part 'data_source.g.dart';
/// Backward compatibility only, use [AlbumRemoteDataSource2] instead
@npLog
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),
final albums = await const AlbumRemoteDataSource2().getAlbums(
account,
[albumFile],
onError: (_, error, stackTrace) {
Error.throwWithStackTrace(error, stackTrace ?? StackTrace.current);
},
);
} 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");
}
return albums.first;
}
@override
getAll(Account account, List<File> 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),
final failed = <String, Map>{};
final albums = await const AlbumRemoteDataSource2().getAlbums(
account,
albumFiles,
onError: (v, error, stackTrace) {
failed[v.path] = {
"file": v,
"error": error,
"stackTrace": stackTrace,
};
},
);
for (final r in results) {
yield r;
var i = 0;
for (final af in albumFiles) {
final v = failed[af.path];
if (v != null) {
yield ExceptionEvent(v["error"], v["stackTrace"]);
} else {
yield albums[i++];
}
}
}
@override
create(Account account, Album album) async {
_log.info("[create]");
final fileName = _makeAlbumFileName();
final filePath =
"${remote_storage_util.getRemoteAlbumsDir(account)}/$fileName";
final c = KiwiContainer().resolve<DiContainer>();
await PutFileBinary(c.fileRepo)(account, filePath,
const Utf8Encoder().convert(jsonEncode(album.toRemoteJson())),
shouldCreateMissingDir: true);
// query album file
final newFile = await LsSingleFile(c)(account, filePath);
return album.copyWith(albumFile: OrNull(newFile));
return const AlbumRemoteDataSource2().create(account, album);
}
@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 = clock.now().millisecondsSinceEpoch;
final random = Random().nextInt(0xFFFFFF);
return "${timestamp.toRadixString(16)}-${random.toRadixString(16).padLeft(6, '0')}.nc_album.json";
return const AlbumRemoteDataSource2().update(account, album);
}
}
/// Backward compatibility only, use [AlbumSqliteDbDataSource2] instead
@npLog
class AlbumSqliteDbDataSource implements AlbumDataSource {
AlbumSqliteDbDataSource(this._c);
@ -119,64 +89,29 @@ class AlbumSqliteDbDataSource implements AlbumDataSource {
getAll(Account account, List<File> albumFiles) async* {
_log.info(
"[getAll] ${albumFiles.map((f) => f.filename).toReadableString()}");
late final List<sql.CompleteFile> dbFiles;
late final List<sql.AlbumWithShare> 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 = <int, sql.CompleteFile>{};
for (var f in dbFiles) {
fileRowIdMap[f.file.rowId] = f;
}
final fileIdMap = <int, Map>{};
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,
final failed = <String, Map>{};
final albums = await AlbumSqliteDbDataSource2(_c.sqliteDb).getAlbums(
account,
albumFiles,
onError: (v, error, stackTrace) {
failed[v.path] = {
"file": v,
"error": error,
"stackTrace": stackTrace,
};
}
if (s.share != null) {
(fileIdMap[f.file.fileId]!["shares"] ??= <sql.AlbumShare>[])
.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();
},
);
var i = 0;
for (final af in albumFiles) {
final v = failed[af.path];
if (v != null) {
if (v["error"] is CacheNotFoundException) {
yield const CacheNotFoundException();
} else {
try {
final f = SqliteFileConverter.fromSql(
account.userId.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);
yield ExceptionEvent(v["error"], v["stackTrace"]);
}
} else {
yield albums[i++];
}
}
}
@ -184,64 +119,22 @@ class AlbumSqliteDbDataSource implements AlbumDataSource {
@override
create(Account account, Album album) async {
_log.info("[create]");
throw UnimplementedError();
return AlbumSqliteDbDataSource2(_c.sqliteDb).create(account, album);
}
@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, album.albumFile!.etag!);
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<int?> _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;
return AlbumSqliteDbDataSource2(_c.sqliteDb).update(account, album);
}
final DiContainer _c;
}
/// Backward compatibility only, use [CachedAlbumRepo2] instead
@npLog
class AlbumCachedDataSource implements AlbumDataSource {
AlbumCachedDataSource(DiContainer c)
: _sqliteDbSrc = AlbumSqliteDbDataSource(c);
AlbumCachedDataSource(DiContainer c) : sqliteDb = c.sqliteDb;
@override
get(Account account, File albumFile) async {
@ -251,58 +144,31 @@ class AlbumCachedDataSource implements AlbumDataSource {
@override
getAll(Account account, List<File> 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;
}
final repo = CachedAlbumRepo2(
const AlbumRemoteDataSource2(),
AlbumSqliteDbDataSource2(sqliteDb),
);
final albums = await repo.getAlbums(account, albumFiles).last;
for (final a in albums) {
yield a;
}
}
@override
update(Account account, Album album) async {
await _remoteSrc.update(account, album);
await _sqliteDbSrc.update(account, album);
update(Account account, Album album) {
return CachedAlbumRepo2(
const AlbumRemoteDataSource2(),
AlbumSqliteDbDataSource2(sqliteDb),
).update(account, album);
}
@override
create(Account account, Album album) => _remoteSrc.create(account, album);
Future<void> _cacheResult(Account account, Album result) {
return _sqliteDbSrc.update(account, result);
create(Account account, Album album) {
return CachedAlbumRepo2(
const AlbumRemoteDataSource2(),
AlbumSqliteDbDataSource2(sqliteDb),
).create(account, album);
}
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;
final sql.SqliteDb sqliteDb;
}

View file

@ -0,0 +1,252 @@
import 'dart:convert';
import 'dart:math';
import 'package:clock/clock.dart';
import 'package:collection/collection.dart';
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/repo2.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/database.dart' as sql;
import 'package:nc_photos/entity/sqlite/type_converter.dart' as sql;
import 'package:nc_photos/exception.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';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/type.dart';
part 'data_source2.g.dart';
@npLog
class AlbumRemoteDataSource2 implements AlbumDataSource2 {
const AlbumRemoteDataSource2();
@override
Future<List<Album>> getAlbums(
Account account,
List<File> albumFiles, {
ErrorWithValueHandler<File>? onError,
}) async {
final results = await Future.wait(albumFiles.map((f) async {
try {
return await _getSingle(account, f);
} catch (e, stackTrace) {
onError?.call(f, e, stackTrace);
return null;
}
}));
return results.whereNotNull().toList();
}
@override
Future<Album> create(Account account, Album album) async {
_log.info("[create] ${album.name}");
final fileName = _makeAlbumFileName();
final filePath =
"${remote_storage_util.getRemoteAlbumsDir(account)}/$fileName";
final c = KiwiContainer().resolve<DiContainer>();
await PutFileBinary(c.fileRepo)(
account,
filePath,
const Utf8Encoder().convert(jsonEncode(album.toRemoteJson())),
shouldCreateMissingDir: true,
);
// query album file
final newFile = await LsSingleFile(c)(account, filePath);
return album.copyWith(albumFile: OrNull(newFile));
}
@override
Future<void> 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())),
);
}
Future<Album> _getSingle(Account account, File albumFile) async {
_log.info("[_getSingle] Getting ${albumFile.path}");
const fileRepo = FileRepo(FileWebdavDataSource());
final data = await GetFileBinary(fileRepo)(account, albumFile);
try {
final album = Album.fromJson(
jsonDecode(utf8.decode(data)),
upgraderFactory: DefaultAlbumUpgraderFactory(
account: account,
albumFile: albumFile,
logFilePath: albumFile.path,
),
);
return album!.copyWith(
lastUpdated: OrNull(null),
albumFile: OrNull(albumFile),
);
} catch (e, stacktrace) {
dynamic d = data;
try {
d = utf8.decode(data);
} catch (_) {}
_log.severe("[_getSingle] Invalid json data: $d", e, stacktrace);
throw const FormatException("Invalid album format");
}
}
String _makeAlbumFileName() {
// just make up something
final timestamp = clock.now().millisecondsSinceEpoch;
final random = Random().nextInt(0xFFFFFF);
return "${timestamp.toRadixString(16)}-${random.toRadixString(16).padLeft(6, '0')}.nc_album.json";
}
}
@npLog
class AlbumSqliteDbDataSource2 implements AlbumDataSource2 {
const AlbumSqliteDbDataSource2(this.sqliteDb);
@override
Future<List<Album>> getAlbums(
Account account,
List<File> albumFiles, {
ErrorWithValueHandler<File>? onError,
}) async {
late final List<sql.CompleteFile> dbFiles;
late final List<sql.AlbumWithShare> albumWithShares;
await 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 = <int, sql.CompleteFile>{};
for (var f in dbFiles) {
fileRowIdMap[f.file.rowId] = f;
}
final fileIdMap = <int, Map>{};
for (final s in albumWithShares) {
final f = fileRowIdMap[s.album.file];
if (f == null) {
_log.severe(
"[getAlbums] File missing for album (rowId: ${s.album.rowId}");
} else {
fileIdMap[f.file.fileId] ??= {
"file": f,
"album": s.album,
};
if (s.share != null) {
(fileIdMap[f.file.fileId]!["shares"] ??= <sql.AlbumShare>[])
.add(s.share!);
}
}
}
// sort as the input list
return albumFiles
.map((f) {
final item = fileIdMap[f.fileId];
if (item == null) {
// cache not found
onError?.call(
f, const CacheNotFoundException(), StackTrace.current);
return null;
} else {
try {
final queriedFile = sql.SqliteFileConverter.fromSql(
account.userId.toString(), item["file"]);
var dbAlbum = item["album"] as sql.Album;
if (dbAlbum.version < 9) {
dbAlbum = AlbumUpgraderV8(logFilePath: queriedFile.path)
.doDb(dbAlbum)!;
}
return sql.SqliteAlbumConverter.fromSql(
dbAlbum, queriedFile, item["shares"] ?? []);
} catch (e, stackTrace) {
_log.severe("[getAlbums] Failed while converting DB entry", e,
stackTrace);
onError?.call(f, e, stackTrace);
return null;
}
}
})
.whereNotNull()
.toList();
}
@override
Future<Album> create(Account account, Album album) async {
_log.info("[create] ${album.name}");
throw UnimplementedError();
}
@override
Future<void> update(Account account, Album album) async {
_log.info("[update] ${album.albumFile!.path}");
await sqliteDb.use((db) async {
final rowIds =
await db.accountFileRowIdsOf(album.albumFile!, appAccount: account);
final insert = sql.SqliteAlbumConverter.toSql(
album, rowIds.fileRowId, album.albumFile!.etag!);
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<int?> _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 sql.SqliteDb sqliteDb;
}

View file

@ -0,0 +1,22 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'data_source2.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$AlbumRemoteDataSource2NpLog on AlbumRemoteDataSource2 {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("entity.album.data_source2.AlbumRemoteDataSource2");
}
extension _$AlbumSqliteDbDataSource2NpLog on AlbumSqliteDbDataSource2 {
// ignore: unused_element
Logger get _log => log;
static final log =
Logger("entity.album.data_source2.AlbumSqliteDbDataSource2");
}

View file

@ -263,49 +263,3 @@ class AlbumTagProvider extends AlbumDynamicProvider {
static const _type = "tag";
}
/// Smart albums are created only by the app and not the user
abstract class AlbumSmartProvider extends AlbumProviderBase {
AlbumSmartProvider({
DateTime? latestItemTime,
}) : super(latestItemTime: latestItemTime);
@override
AlbumSmartProvider copyWith({
OrNull<DateTime>? latestItemTime,
}) {
// Smart albums do not support copying
throw UnimplementedError();
}
@override
toContentJson() {
// Smart albums do not support saving
throw UnimplementedError();
}
}
/// Memory album is created based on dates
@ToString(extraParams: r"{bool isDeep = false}")
class AlbumMemoryProvider extends AlbumSmartProvider {
AlbumMemoryProvider({
required this.year,
required this.month,
required this.day,
}) : super(latestItemTime: DateTime(year, month, day));
@override
String toString({bool isDeep = false}) => _$toString(isDeep: isDeep);
@override
get props => [
...super.props,
year,
month,
day,
];
final int year;
final int month;
final int day;
}

View file

@ -37,10 +37,3 @@ extension _$AlbumTagProviderToString on AlbumTagProvider {
return "AlbumTagProvider {latestItemTime: $latestItemTime, tags: ${tags.map((t) => t.displayName).toReadableString()}}";
}
}
extension _$AlbumMemoryProviderToString on AlbumMemoryProvider {
String _$toString({bool isDeep = false}) {
// ignore: unnecessary_string_interpolations
return "AlbumMemoryProvider {latestItemTime: $latestItemTime, year: $year, month: $month, day: $day}";
}
}

View file

@ -0,0 +1,152 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/exception.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/type.dart';
part 'repo2.g.dart';
abstract class AlbumRepo2 {
/// Query all [Album]s defined by [albumFiles]
Stream<List<Album>> getAlbums(
Account account,
List<File> albumFiles, {
ErrorWithValueHandler<File>? onError,
});
/// Create a new [album]
Future<Album> create(Account account, Album album);
/// Update an [album]
Future<void> update(Account account, Album album);
}
class BasicAlbumRepo2 implements AlbumRepo2 {
const BasicAlbumRepo2(this.dataSrc);
@override
Stream<List<Album>> getAlbums(
Account account,
List<File> albumFiles, {
ErrorWithValueHandler<File>? onError,
}) async* {
yield await dataSrc.getAlbums(account, albumFiles, onError: onError);
}
@override
Future<Album> create(Account account, Album album) =>
dataSrc.create(account, album);
@override
Future<void> update(Account account, Album album) =>
dataSrc.update(account, album);
final AlbumDataSource2 dataSrc;
}
@npLog
class CachedAlbumRepo2 implements AlbumRepo2 {
const CachedAlbumRepo2(this.remoteDataSrc, this.cacheDataSrc);
@override
Stream<List<Album>> getAlbums(
Account account,
List<File> albumFiles, {
ErrorWithValueHandler<File>? onError,
}) async* {
// get cache
final cached = <Album>[];
final failed = <File>[];
try {
cached.addAll(await cacheDataSrc.getAlbums(
account,
albumFiles,
onError: (f, e, stackTrace) {
failed.add(f);
if (e is CacheNotFoundException) {
// not in cache, normal
} else {
_log.shout("[getAlbums] Cache failure", e, stackTrace);
}
},
));
yield cached;
} catch (e, stackTrace) {
_log.shout("[getAlbums] Failed while getAlbums", e, stackTrace);
}
final cachedGroup = cached.groupListsBy((c) {
try {
return _validateCache(
c, albumFiles.firstWhere(c.albumFile!.compareServerIdentity));
} catch (_) {
return false;
}
});
// query remote
final outdated = [
...failed,
...cachedGroup[false]?.map((e) =>
albumFiles.firstWhere(e.albumFile!.compareServerIdentity)) ??
const <File>[],
];
final remote =
await remoteDataSrc.getAlbums(account, outdated, onError: onError);
yield (cachedGroup[true] ?? []) + remote;
// update cache
for (final a in remote) {
unawaited(cacheDataSrc.update(account, a));
}
}
@override
Future<Album> create(Account account, Album album) =>
remoteDataSrc.create(account, album);
@override
Future<void> update(Account account, Album album) async {
await remoteDataSrc.update(account, album);
try {
await cacheDataSrc.update(account, album);
} catch (e, stackTrace) {
_log.warning("[update] Failed to update cache", e, stackTrace);
}
}
/// Return true if the cached album is considered up to date
bool _validateCache(Album cache, File albumFile) {
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;
}
}
final AlbumDataSource2 remoteDataSrc;
final AlbumDataSource2 cacheDataSrc;
}
abstract class AlbumDataSource2 {
/// Query all [Album]s defined by [albumFiles]
Future<List<Album>> getAlbums(
Account account,
List<File> albumFiles, {
ErrorWithValueHandler<File>? onError,
});
Future<Album> create(Account account, Album album);
Future<void> update(Account account, Album album);
}

View file

@ -1,14 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'list_favorite.dart';
part of 'repo2.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$ListFavoriteNpLog on ListFavorite {
extension _$CachedAlbumRepo2NpLog on CachedAlbumRepo2 {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("use_case.list_favorite.ListFavorite");
static final log = Logger("entity.album.repo2.CachedAlbumRepo2");
}

View file

@ -1,14 +1,12 @@
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/entity/album/item.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/entity/collection_item/album_item_adapter.dart';
import 'package:nc_photos/entity/collection_item/sorter.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/type.dart';
import 'package:to_string/to_string.dart';
import 'package:tuple/tuple.dart';
part 'sort_provider.g.dart';
@ -33,6 +31,22 @@ abstract class AlbumSortProvider with EquatableMixin {
}
}
factory AlbumSortProvider.fromCollectionItemSort(
CollectionItemSort itemSort) {
switch (itemSort) {
case CollectionItemSort.manual:
return const AlbumNullSortProvider();
case CollectionItemSort.dateAscending:
return const AlbumTimeSortProvider(isAscending: true);
case CollectionItemSort.dateDescending:
return const AlbumTimeSortProvider(isAscending: false);
case CollectionItemSort.nameAscending:
return const AlbumFilenameSortProvider(isAscending: true);
case CollectionItemSort.nameDescending:
return const AlbumFilenameSortProvider(isAscending: false);
}
}
JsonObj toJson() {
String getType() {
if (this is AlbumNullSortProvider) {
@ -53,7 +67,31 @@ abstract class AlbumSortProvider with EquatableMixin {
}
/// Return a sorted copy of [items]
List<AlbumItem> sort(List<AlbumItem> items);
List<AlbumItem> sort(List<AlbumItem> items) {
final type = toCollectionItemSort();
final sorter = CollectionSorter.fromSortType(type);
return sorter(items.map(AlbumAdaptedCollectionItem.fromItem).toList())
.whereType<AlbumAdaptedCollectionItem>()
.map((e) => e.albumItem)
.toList();
}
CollectionItemSort toCollectionItemSort() {
final that = this;
if (that is AlbumNullSortProvider) {
return CollectionItemSort.manual;
} else if (that is AlbumTimeSortProvider) {
return that.isAscending
? CollectionItemSort.dateAscending
: CollectionItemSort.dateDescending;
} else if (that is AlbumFilenameSortProvider) {
return that.isAscending
? CollectionItemSort.nameAscending
: CollectionItemSort.nameDescending;
} else {
throw StateError("Unknown type: ${sort.runtimeType}");
}
}
JsonObj _toContentJson();
@ -72,11 +110,6 @@ class AlbumNullSortProvider extends AlbumSortProvider {
@override
String toString() => _$toString();
@override
sort(List<AlbumItem> items) {
return List.from(items);
}
@override
get props => [];
@ -124,37 +157,6 @@ class AlbumTimeSortProvider extends AlbumReversibleSortProvider {
@override
String toString() => _$toString();
@override
sort(List<AlbumItem> items) {
DateTime? prevFileTime;
return items
.map((e) {
if (e is AlbumFileItem) {
// take the file time
prevFileTime = e.file.bestDateTime;
}
// for non file items, use the sibling file's time
return Tuple2(prevFileTime, e);
})
.stableSorted((x, y) {
if (x.item1 == null && y.item1 == null) {
return 0;
} else if (x.item1 == null) {
return -1;
} else if (y.item1 == null) {
return 1;
} else {
if (isAscending) {
return x.item1!.compareTo(y.item1!);
} else {
return y.item1!.compareTo(x.item1!);
}
}
})
.map((e) => e.item2)
.toList();
}
static const _type = "time";
}
@ -174,36 +176,5 @@ class AlbumFilenameSortProvider extends AlbumReversibleSortProvider {
@override
String toString() => _$toString();
@override
sort(List<AlbumItem> items) {
String? prevFilename;
return items
.map((e) {
if (e is AlbumFileItem) {
// take the file name
prevFilename = e.file.filename;
}
// for non file items, use the sibling file's name
return Tuple2(prevFilename, e);
})
.stableSorted((x, y) {
if (x.item1 == null && y.item1 == null) {
return 0;
} else if (x.item1 == null) {
return -1;
} else if (y.item1 == null) {
return 1;
} else {
if (isAscending) {
return compareNatural(x.item1!, y.item1!);
} else {
return compareNatural(y.item1!, x.item1!);
}
}
})
.map((e) => e.item2)
.toList();
}
static const _type = "filename";
}

View file

@ -1,8 +1,13 @@
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/exif.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/sqlite/database.dart' as sql;
import 'package:nc_photos/object_extension.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/ci_string.dart';
import 'package:np_common/type.dart';
@ -11,7 +16,8 @@ import 'package:tuple/tuple.dart';
part 'upgrader.g.dart';
abstract class AlbumUpgrader {
JsonObj? call(JsonObj json);
JsonObj? doJson(JsonObj json);
sql.Album? doDb(sql.Album dbObj);
}
/// Upgrade v1 Album to v2
@ -22,14 +28,17 @@ class AlbumUpgraderV1 implements AlbumUpgrader {
});
@override
call(JsonObj json) {
doJson(JsonObj json) {
// v1 album items are corrupted in one of the updates, drop it
_log.fine("[call] Upgrade v1 Album for file: $logFilePath");
_log.fine("[doJson] Upgrade v1 Album for file: $logFilePath");
final result = JsonObj.from(json);
result["items"] = [];
return result;
}
@override
sql.Album? doDb(sql.Album dbObj) => null;
/// File path for logging only
final String? logFilePath;
}
@ -42,9 +51,9 @@ class AlbumUpgraderV2 implements AlbumUpgrader {
});
@override
call(JsonObj json) {
doJson(JsonObj json) {
// move v2 items to v3 provider
_log.fine("[call] Upgrade v2 Album for file: $logFilePath");
_log.fine("[doJson] Upgrade v2 Album for file: $logFilePath");
final result = JsonObj.from(json);
result["provider"] = <String, dynamic>{
"type": "static",
@ -62,6 +71,9 @@ class AlbumUpgraderV2 implements AlbumUpgrader {
return result;
}
@override
sql.Album? doDb(sql.Album dbObj) => null;
/// File path for logging only
final String? logFilePath;
}
@ -74,9 +86,9 @@ class AlbumUpgraderV3 implements AlbumUpgrader {
});
@override
call(JsonObj json) {
doJson(JsonObj json) {
// move v3 items to v4 provider
_log.fine("[call] Upgrade v3 Album for file: $logFilePath");
_log.fine("[doJson] Upgrade v3 Album for file: $logFilePath");
final result = JsonObj.from(json);
// add the descending time sort provider
result["sortProvider"] = <String, dynamic>{
@ -88,6 +100,9 @@ class AlbumUpgraderV3 implements AlbumUpgrader {
return result;
}
@override
sql.Album? doDb(sql.Album dbObj) => null;
/// File path for logging only
final String? logFilePath;
}
@ -100,8 +115,8 @@ class AlbumUpgraderV4 implements AlbumUpgrader {
});
@override
call(JsonObj json) {
_log.fine("[call] Upgrade v4 Album for file: $logFilePath");
doJson(JsonObj json) {
_log.fine("[doJson] Upgrade v4 Album for file: $logFilePath");
final result = JsonObj.from(json);
try {
if (result["provider"]["type"] != "static") {
@ -151,11 +166,14 @@ class AlbumUpgraderV4 implements AlbumUpgrader {
} catch (e, stackTrace) {
// this upgrade is not a must, if it failed then just leave it and it'll
// be upgraded the next time the album is saved
_log.shout("[call] Failed while upgrade", e, stackTrace);
_log.shout("[doJson] Failed while upgrade", e, stackTrace);
}
return result;
}
@override
sql.Album? doDb(sql.Album dbObj) => null;
/// File path for logging only
final String? logFilePath;
}
@ -170,8 +188,8 @@ class AlbumUpgraderV5 implements AlbumUpgrader {
});
@override
call(JsonObj json) {
_log.fine("[call] Upgrade v5 Album for file: $logFilePath");
doJson(JsonObj json) {
_log.fine("[doJson] Upgrade v5 Album for file: $logFilePath");
final result = JsonObj.from(json);
try {
if (result["provider"]["type"] != "static") {
@ -192,11 +210,14 @@ class AlbumUpgraderV5 implements AlbumUpgrader {
} catch (e, stackTrace) {
// this upgrade is not a must, if it failed then just leave it and it'll
// be upgraded the next time the album is saved
_log.shout("[call] Failed while upgrade", e, stackTrace);
_log.shout("[doJson] Failed while upgrade", e, stackTrace);
}
return result;
}
@override
sql.Album? doDb(sql.Album dbObj) => null;
final Account account;
final File? albumFile;
@ -212,11 +233,14 @@ class AlbumUpgraderV6 implements AlbumUpgrader {
});
@override
call(JsonObj json) {
_log.fine("[call] Upgrade v6 Album for file: $logFilePath");
doJson(JsonObj json) {
_log.fine("[doJson] Upgrade v6 Album for file: $logFilePath");
return json;
}
@override
sql.Album? doDb(sql.Album dbObj) => null;
/// File path for logging only
final String? logFilePath;
}
@ -229,11 +253,103 @@ class AlbumUpgraderV7 implements AlbumUpgrader {
});
@override
call(JsonObj json) {
_log.fine("[call] Upgrade v7 Album for file: $logFilePath");
doJson(JsonObj json) {
_log.fine("[doJson] Upgrade v7 Album for file: $logFilePath");
return json;
}
@override
sql.Album? doDb(sql.Album dbObj) => null;
/// File path for logging only
final String? logFilePath;
}
/// Upgrade v8 Album to v9
@npLog
class AlbumUpgraderV8 implements AlbumUpgrader {
const AlbumUpgraderV8({
this.logFilePath,
});
@override
JsonObj? doJson(JsonObj json) {
_log.fine("[doJson] Upgrade v8 Album for file: $logFilePath");
final result = JsonObj.from(json);
if (result["coverProvider"]["type"] == "manual") {
final content = (result["coverProvider"]["content"]["coverFile"] as Map)
.cast<String, dynamic>();
final fd = _fileJsonToFileDescriptorJson(content);
// some very old album file may contain files w/o id
if (fd["fdId"] != null) {
result["coverProvider"]["content"]["coverFile"] = fd;
} else {
result["coverProvider"]["content"] = {};
}
} else if (result["coverProvider"]["type"] == "auto") {
final content = (result["coverProvider"]["content"]["coverFile"] as Map?)
?.cast<String, dynamic>();
if (content != null) {
final fd = _fileJsonToFileDescriptorJson(content);
if (fd["fdId"] != null) {
result["coverProvider"]["content"]["coverFile"] = fd;
} else {
result["coverProvider"]["content"] = {};
}
}
}
return result;
}
@override
sql.Album? doDb(sql.Album dbObj) {
_log.fine("[doDb] Upgrade v8 Album for file: $logFilePath");
if (dbObj.coverProviderType == "manual") {
final content = (jsonDecode(dbObj.coverProviderContent) as Map)
.cast<String, dynamic>();
final converted = _fileJsonToFileDescriptorJson(
(content["coverFile"] as Map).cast<String, dynamic>());
if (converted["fdId"] != null) {
return dbObj.copyWith(
coverProviderContent: jsonEncode({"coverFile": converted}),
);
} else {
return dbObj.copyWith(coverProviderContent: "{}");
}
} else if (dbObj.coverProviderType == "auto") {
final content = (jsonDecode(dbObj.coverProviderContent) as Map)
.cast<String, dynamic>();
if (content["coverFile"] != null) {
final converted = _fileJsonToFileDescriptorJson(
(content["coverFile"] as Map).cast<String, dynamic>());
if (converted["fdId"] != null) {
return dbObj.copyWith(
coverProviderContent: jsonEncode({"coverFile": converted}),
);
} else {
return dbObj.copyWith(coverProviderContent: "{}");
}
}
}
return dbObj;
}
static JsonObj _fileJsonToFileDescriptorJson(JsonObj json) {
return {
"fdPath": json["path"],
"fdId": json["fileId"],
"fdMime": json["contentType"],
"fdIsArchived": json["isArchived"] ?? false,
// File.isFavorite is serialized as int
"fdIsFavorite": json["isFavorite"] == 1,
"fdDateTime": json["overrideDateTime"] ??
(json["metadata"]?["exif"]?["DateTimeOriginal"] as String?)?.run(
(d) => Exif.dateTimeFormat.parse(d).toUtc().toIso8601String()) ??
json["lastModified"] ??
clock.now().toUtc().toIso8601String(),
};
}
/// File path for logging only
final String? logFilePath;
}
@ -248,6 +364,7 @@ abstract class AlbumUpgraderFactory {
AlbumUpgraderV5? buildV5();
AlbumUpgraderV6? buildV6();
AlbumUpgraderV7? buildV7();
AlbumUpgraderV8? buildV8();
}
class DefaultAlbumUpgraderFactory extends AlbumUpgraderFactory {
@ -282,6 +399,9 @@ class DefaultAlbumUpgraderFactory extends AlbumUpgraderFactory {
@override
buildV7() => AlbumUpgraderV7(logFilePath: logFilePath);
@override
AlbumUpgraderV8? buildV8() => AlbumUpgraderV8(logFilePath: logFilePath);
final Account account;
final File? albumFile;

View file

@ -54,3 +54,10 @@ extension _$AlbumUpgraderV7NpLog on AlbumUpgraderV7 {
static final log = Logger("entity.album.upgrader.AlbumUpgraderV7");
}
extension _$AlbumUpgraderV8NpLog on AlbumUpgraderV8 {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("entity.album.upgrader.AlbumUpgraderV8");
}

View file

@ -1,41 +0,0 @@
import 'package:collection/collection.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:tuple/tuple.dart';
enum AlbumSort {
dateDescending,
dateAscending,
nameAscending,
nameDescending,
}
List<Album> sorted(List<Album> albums, AlbumSort by) {
final isAscending = _isSortAscending(by);
return albums
.map<Tuple2<dynamic, Album>>((e) {
switch (by) {
case AlbumSort.nameAscending:
case AlbumSort.nameDescending:
return Tuple2(e.name, e);
case AlbumSort.dateAscending:
case AlbumSort.dateDescending:
return Tuple2(e.provider.latestItemTime ?? e.lastUpdated, e);
}
})
.sorted((a, b) {
final x = isAscending ? a : b;
final y = isAscending ? b : a;
final tmp = x.item1.compareTo(y.item1);
if (tmp != 0) {
return tmp;
} else {
return x.item2.name.compareTo(y.item2.name);
}
})
.map((e) => e.item2)
.toList();
}
bool _isSortAscending(AlbumSort sort) =>
sort == AlbumSort.dateAscending || sort == AlbumSort.nameAscending;

View file

@ -0,0 +1,148 @@
import 'package:copy_with/copy_with.dart';
import 'package:equatable/equatable.dart';
import 'package:nc_photos/entity/collection/util.dart';
import 'package:nc_photos/entity/collection_item/sorter.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:to_string/to_string.dart';
part 'collection.g.dart';
/// Describe a group of items
@genCopyWith
@toString
class Collection with EquatableMixin {
const Collection({
required this.name,
required this.contentProvider,
});
@override
String toString() => _$toString();
bool compareIdentity(Collection other) => other.id == id;
int get identityHashCode => id.hashCode;
/// A unique id for each collection. The value is divided into two parts in
/// the format XXXX-YYY...YYY, where XXXX is a four-character code
/// representing the content provider type, and YYY is an implementation
/// detail of each providers
String get id => "${contentProvider.fourCc}-${contentProvider.id}";
/// See [CollectionContentProvider.count]
int? get count => contentProvider.count;
/// See [CollectionContentProvider.lastModified]
DateTime get lastModified => contentProvider.lastModified;
/// See [CollectionContentProvider.capabilities]
List<CollectionCapability> get capabilities => contentProvider.capabilities;
/// See [CollectionContentProvider.itemSort]
CollectionItemSort get itemSort => contentProvider.itemSort;
/// See [CollectionContentProvider.shares]
List<CollectionShare> get shares => contentProvider.shares;
/// See [CollectionContentProvider.getCoverUrl]
String? getCoverUrl(
int width,
int height, {
bool? isKeepAspectRatio,
}) =>
contentProvider.getCoverUrl(
width,
height,
isKeepAspectRatio: isKeepAspectRatio,
);
CollectionSorter getSorter() => CollectionSorter.fromSortType(itemSort);
/// See [CollectionContentProvider.isDynamicCollection]
bool get isDynamicCollection => contentProvider.isDynamicCollection;
/// See [CollectionContentProvider.isPendingSharedAlbum]
bool get isPendingSharedAlbum => contentProvider.isPendingSharedAlbum;
@override
List<Object?> get props => [
name,
contentProvider,
];
final String name;
final CollectionContentProvider contentProvider;
}
enum CollectionCapability {
// add/remove items
manualItem,
// sort the items
sort,
// rearrange item manually
manualSort,
// can freely rename album
rename,
// text labels
labelItem,
// set the cover image
manualCover,
// share the collection with other user on the same server
share,
}
/// Provide the actual content of a collection
abstract class CollectionContentProvider with EquatableMixin {
const CollectionContentProvider();
/// Unique FourCC of this provider type
String get fourCc;
/// Return the unique id of this collection
String get id;
/// Return the number of items in this collection, or null if not supported
int? get count;
/// Return the date time of this collection. Generally this is the date time
/// of the latest child
DateTime get lastModified;
/// Return the capabilities of the collection
///
/// Notice that the capabilities returned here represent all the capabilities
/// that this implementation supports. In practice there may be extra runtime
/// requirements that mask some of them (e.g., user permissions)
List<CollectionCapability> get capabilities;
/// Return the sort type
CollectionItemSort get itemSort;
/// Return list of users who have access to this collection, excluding the
/// current user
List<CollectionShare> get shares;
/// Return the URL of the cover image if available
///
/// The [width] and [height] are provided as a hint only, implementations are
/// free to ignore them if it's not supported
///
/// [isKeepAspectRatio] is only a hint and implementations may ignore it
String? getCoverUrl(
int width,
int height, {
bool? isKeepAspectRatio,
});
/// Return whether this is a dynamic collection
///
/// A collection is defined as a dynamic one when the items are not specified
/// explicitly by the user, but rather derived from some conditions
bool get isDynamicCollection;
/// Return whether this is a shared album pending to be added
///
/// In some implementation, shared album does not immediately get added to the
/// collections list
bool get isPendingSharedAlbum;
}

View file

@ -0,0 +1,48 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'collection.dart';
// **************************************************************************
// CopyWithLintRuleGenerator
// **************************************************************************
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class $CollectionCopyWithWorker {
Collection call({String? name, CollectionContentProvider? contentProvider});
}
class _$CollectionCopyWithWorkerImpl implements $CollectionCopyWithWorker {
_$CollectionCopyWithWorkerImpl(this.that);
@override
Collection call({dynamic name, dynamic contentProvider}) {
return Collection(
name: name as String? ?? that.name,
contentProvider: contentProvider as CollectionContentProvider? ??
that.contentProvider);
}
final Collection that;
}
extension $CollectionCopyWith on Collection {
$CollectionCopyWithWorker get copyWith => _$copyWith;
$CollectionCopyWithWorker get _$copyWith =>
_$CollectionCopyWithWorkerImpl(this);
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$CollectionToString on Collection {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "Collection {name: $name, contentProvider: $contentProvider}";
}
}

View file

@ -0,0 +1,115 @@
import 'package:flutter/foundation.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/adapter/album.dart';
import 'package:nc_photos/entity/collection/adapter/location_group.dart';
import 'package:nc_photos/entity/collection/adapter/memory.dart';
import 'package:nc_photos/entity/collection/adapter/nc_album.dart';
import 'package:nc_photos/entity/collection/adapter/person.dart';
import 'package:nc_photos/entity/collection/adapter/tag.dart';
import 'package:nc_photos/entity/collection/content_provider/album.dart';
import 'package:nc_photos/entity/collection/content_provider/location_group.dart';
import 'package:nc_photos/entity/collection/content_provider/memory.dart';
import 'package:nc_photos/entity/collection/content_provider/nc_album.dart';
import 'package:nc_photos/entity/collection/content_provider/person.dart';
import 'package:nc_photos/entity/collection/content_provider/tag.dart';
import 'package:nc_photos/entity/collection/util.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/collection_item/new_item.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/sharee.dart';
import 'package:nc_photos/or_null.dart';
import 'package:np_common/ci_string.dart';
import 'package:np_common/type.dart';
abstract class CollectionAdapter {
const CollectionAdapter();
static CollectionAdapter of(
DiContainer c, Account account, Collection collection) {
switch (collection.contentProvider.runtimeType) {
case CollectionAlbumProvider:
return CollectionAlbumAdapter(c, account, collection);
case CollectionLocationGroupProvider:
return CollectionLocationGroupAdapter(c, account, collection);
case CollectionMemoryProvider:
return CollectionMemoryAdapter(c, account, collection);
case CollectionNcAlbumProvider:
return CollectionNcAlbumAdapter(c, account, collection);
case CollectionPersonProvider:
return CollectionPersonAdapter(c, account, collection);
case CollectionTagProvider:
return CollectionTagAdapter(c, account, collection);
default:
throw UnsupportedError(
"Unknown type: ${collection.contentProvider.runtimeType}");
}
}
/// List items inside this collection
Stream<List<CollectionItem>> listItem();
/// Add [files] to this collection and return the added count
Future<int> addFiles(
List<FileDescriptor> files, {
ErrorWithValueHandler<FileDescriptor>? onError,
required ValueChanged<Collection> onCollectionUpdated,
});
/// Edit this collection
Future<Collection> edit({
String? name,
List<CollectionItem>? items,
CollectionItemSort? itemSort,
OrNull<FileDescriptor>? cover,
List<CollectionItem>? knownItems,
});
/// Remove [items] from this collection and return the removed count
Future<int> removeItems(
List<CollectionItem> items, {
ErrorWithValueIndexedHandler<CollectionItem>? onError,
required ValueChanged<Collection> onCollectionUpdated,
});
/// Share the collection with [sharee]
Future<CollectionShareResult> share(
Sharee sharee, {
required ValueChanged<Collection> onCollectionUpdated,
});
/// Unshare the collection with a user
Future<CollectionShareResult> unshare(
CiString userId, {
required ValueChanged<Collection> onCollectionUpdated,
});
/// Import a pending shared collection and return the resulting collection
Future<Collection> importPendingShared();
/// Convert a [NewCollectionItem] to an adapted one
Future<CollectionItem> adaptToNewItem(NewCollectionItem original);
bool isItemRemovable(CollectionItem item);
/// Remove this collection
Future<void> remove();
/// Return if this capability is allowed
bool isPermitted(CollectionCapability capability);
/// Return if the cover of this collection has been manually set
bool isManualCover();
/// Called when the collection items belonging to this collection is first
/// loaded
Future<Collection?> updatePostLoad(List<CollectionItem> items);
}
abstract class CollectionItemAdapter {
const CollectionItemAdapter();
CollectionItem toItem();
}

View file

@ -0,0 +1,83 @@
import 'package:flutter/foundation.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/adapter.dart';
import 'package:nc_photos/entity/collection/util.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/sharee.dart';
import 'package:nc_photos/or_null.dart';
import 'package:np_common/ci_string.dart';
import 'package:np_common/type.dart';
/// A read-only collection that does not support modifying its items
mixin CollectionAdapterReadOnlyTag implements CollectionAdapter {
@override
Future<int> addFiles(
List<FileDescriptor> files, {
ErrorWithValueHandler<FileDescriptor>? onError,
required ValueChanged<Collection> onCollectionUpdated,
}) {
throw UnsupportedError("Operation not supported");
}
@override
Future<Collection> edit({
String? name,
List<CollectionItem>? items,
CollectionItemSort? itemSort,
OrNull<FileDescriptor>? cover,
List<CollectionItem>? knownItems,
}) {
throw UnsupportedError("Operation not supported");
}
@override
Future<int> removeItems(
List<CollectionItem> items, {
ErrorWithValueIndexedHandler<CollectionItem>? onError,
required ValueChanged<Collection> onCollectionUpdated,
}) {
throw UnsupportedError("Operation not supported");
}
@override
bool isItemRemovable(CollectionItem item) => false;
@override
bool isManualCover() => false;
@override
Future<Collection?> updatePostLoad(List<CollectionItem> items) =>
Future.value(null);
}
mixin CollectionAdapterUnremovableTag implements CollectionAdapter {
@override
Future<void> remove() {
throw UnsupportedError("Operation not supported");
}
}
mixin CollectionAdapterUnshareableTag implements CollectionAdapter {
@override
Future<CollectionShareResult> share(
Sharee sharee, {
required ValueChanged<Collection> onCollectionUpdated,
}) {
throw UnsupportedError("Operation not supported");
}
@override
Future<CollectionShareResult> unshare(
CiString userId, {
required ValueChanged<Collection> onCollectionUpdated,
}) {
throw UnsupportedError("Operation not supported");
}
@override
Future<Collection> importPendingShared() {
throw UnsupportedError("Operation not supported");
}
}

View file

@ -0,0 +1,318 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/debug_util.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/collection.dart';
import 'package:nc_photos/entity/collection/adapter.dart';
import 'package:nc_photos/entity/collection/builder.dart';
import 'package:nc_photos/entity/collection/content_provider/album.dart';
import 'package:nc_photos/entity/collection/util.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/collection_item/album_item_adapter.dart';
import 'package:nc_photos/entity/collection_item/new_item.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/sharee.dart';
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/use_case/album/add_file_to_album.dart';
import 'package:nc_photos/use_case/album/edit_album.dart';
import 'package:nc_photos/use_case/album/remove_album.dart';
import 'package:nc_photos/use_case/album/remove_from_album.dart';
import 'package:nc_photos/use_case/album/share_album_with_user.dart';
import 'package:nc_photos/use_case/album/unshare_album_with_user.dart';
import 'package:nc_photos/use_case/import_pending_shared_album.dart';
import 'package:nc_photos/use_case/preprocess_album.dart';
import 'package:nc_photos/use_case/unimport_shared_album.dart';
import 'package:nc_photos/use_case/update_album_with_actual_items.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/ci_string.dart';
import 'package:np_common/type.dart';
import 'package:tuple/tuple.dart';
part 'album.g.dart';
@npLog
class CollectionAlbumAdapter implements CollectionAdapter {
CollectionAlbumAdapter(this._c, this.account, this.collection)
: assert(require(_c)),
_provider = collection.contentProvider as CollectionAlbumProvider;
static bool require(DiContainer c) => PreProcessAlbum.require(c);
@override
Stream<List<CollectionItem>> listItem() async* {
final items = await PreProcessAlbum(_c)(account, _provider.album);
yield items.map<CollectionItem>((i) {
if (i is AlbumFileItem) {
return CollectionFileItemAlbumAdapter(i);
} else if (i is AlbumLabelItem) {
return CollectionLabelItemAlbumAdapter(i);
} else {
_log.shout("[listItem] Unknown item type: ${i.runtimeType}");
throw UnimplementedError("Unknown item type: ${i.runtimeType}");
}
}).toList();
}
@override
Future<int> addFiles(
List<FileDescriptor> files, {
ErrorWithValueHandler<FileDescriptor>? onError,
required ValueChanged<Collection> onCollectionUpdated,
}) async {
try {
final newAlbum =
await AddFileToAlbum(_c)(account, _provider.album, files);
onCollectionUpdated(CollectionBuilder.byAlbum(account, newAlbum));
return files.length;
} catch (e, stackTrace) {
for (final f in files) {
onError?.call(f, e, stackTrace);
}
return 0;
}
}
@override
Future<Collection> edit({
String? name,
List<CollectionItem>? items,
CollectionItemSort? itemSort,
OrNull<FileDescriptor>? cover,
List<CollectionItem>? knownItems,
}) async {
assert(name != null || items != null || itemSort != null || cover != null);
final newItems = items?.run((items) => items
.map((e) {
if (e is AlbumAdaptedCollectionItem) {
return e.albumItem;
} else if (e is NewCollectionLabelItem) {
// new labels
return AlbumLabelItem(
addedBy: account.userId,
addedAt: e.createdAt,
text: e.text,
);
} else {
_log.severe("[edit] Unsupported type: ${e.runtimeType}");
return null;
}
})
.whereNotNull()
.toList());
final newAlbum = await EditAlbum(_c)(
account,
_provider.album,
name: name,
items: newItems,
itemSort: itemSort,
cover: cover,
knownItems: knownItems
?.whereType<AlbumAdaptedCollectionItem>()
.map((e) => e.albumItem)
.toList(),
);
return collection.copyWith(
name: name,
contentProvider: _provider.copyWith(album: newAlbum),
);
}
@override
Future<int> removeItems(
List<CollectionItem> items, {
ErrorWithValueIndexedHandler<CollectionItem>? onError,
required ValueChanged<Collection> onCollectionUpdated,
}) async {
try {
final group = items
.withIndex()
.groupListsBy((e) => e.item2 is AlbumAdaptedCollectionItem);
var failed = 0;
if (group[true]?.isNotEmpty ?? false) {
final newAlbum = await RemoveFromAlbum(_c)(
account,
_provider.album,
group[true]!
.map((e) => e.item2)
.cast<AlbumAdaptedCollectionItem>()
.map((e) => e.albumItem)
.toList(),
onError: (i, item, e, stackTrace) {
++failed;
final actualIndex = group[true]![i].item1;
try {
onError?.call(actualIndex, items[actualIndex], e, stackTrace);
} catch (e, stackTrace) {
_log.severe("[removeItems] Unknown error", e, stackTrace);
}
},
);
onCollectionUpdated(collection.copyWith(
contentProvider: _provider.copyWith(
album: newAlbum,
),
));
}
for (final pair in (group[false] ?? const <Tuple2<int, int>>[])) {
final actualIndex = pair.item1;
onError?.call(
actualIndex,
items[actualIndex],
UnsupportedError(
"Unsupported item type: ${items[actualIndex].runtimeType}"),
StackTrace.current,
);
}
return (group[true] ?? []).length - failed;
} catch (e, stackTrace) {
for (final pair in items.withIndex()) {
onError?.call(pair.item1, pair.item2, e, stackTrace);
}
return 0;
}
}
@override
Future<CollectionShareResult> share(
Sharee sharee, {
required ValueChanged<Collection> onCollectionUpdated,
}) async {
var fileFailed = false;
final newAlbum = await ShareAlbumWithUser(_c.shareRepo, _c.albumRepo)(
account,
_provider.album,
sharee,
onShareFileFailed: (f, e, stackTrace) {
_log.severe("[share] Failed to share file: ${logFilename(f.path)}", e,
stackTrace);
fileFailed = true;
},
);
onCollectionUpdated(CollectionBuilder.byAlbum(account, newAlbum));
return fileFailed
? CollectionShareResult.partial
: CollectionShareResult.ok;
}
@override
Future<CollectionShareResult> unshare(
CiString userId, {
required ValueChanged<Collection> onCollectionUpdated,
}) async {
var fileFailed = false;
final newAlbum = await UnshareAlbumWithUser(_c)(
account,
_provider.album,
userId,
onUnshareFileFailed: (f, e, stackTrace) {
_log.severe("[unshare] Failed to unshare file: ${logFilename(f.path)}",
e, stackTrace);
fileFailed = true;
},
);
onCollectionUpdated(CollectionBuilder.byAlbum(account, newAlbum));
return fileFailed
? CollectionShareResult.partial
: CollectionShareResult.ok;
}
@override
Future<Collection> importPendingShared() async {
final newAlbum =
await ImportPendingSharedAlbum(_c)(account, _provider.album);
return CollectionBuilder.byAlbum(account, newAlbum);
}
@override
Future<CollectionItem> adaptToNewItem(NewCollectionItem original) async {
if (original is NewCollectionFileItem) {
final item = AlbumStaticProvider.of(_provider.album)
.items
.whereType<AlbumFileItem>()
.firstWhere((e) => e.file.compareServerIdentity(original.file));
return CollectionFileItemAlbumAdapter(item);
} else if (original is NewCollectionLabelItem) {
final item = AlbumStaticProvider.of(_provider.album)
.items
.whereType<AlbumLabelItem>()
.sorted((a, b) => a.addedAt.compareTo(b.addedAt))
.reversed
.firstWhere((e) => e.text == original.text);
return CollectionLabelItemAlbumAdapter(item);
} else {
throw UnsupportedError("Unsupported type: ${original.runtimeType}");
}
}
@override
bool isItemRemovable(CollectionItem item) {
if (_provider.album.provider is! AlbumStaticProvider) {
return false;
}
if (_provider.album.albumFile?.isOwned(account.userId) == true) {
return true;
}
if (item is! AlbumAdaptedCollectionItem) {
_log.warning("[isItemRemovable] Unknown item type: ${item.runtimeType}");
return true;
}
return item.albumItem.addedBy == account.userId;
}
@override
Future<void> remove() {
if (_provider.album.albumFile?.isOwned(account.userId) == true) {
return RemoveAlbum(_c)(account, _provider.album);
} else {
return UnimportSharedAlbum(_c)(account, _provider.album);
}
}
@override
bool isPermitted(CollectionCapability capability) {
if (!_provider.capabilities.contains(capability)) {
return false;
}
if (_provider.album.albumFile?.isOwned(account.userId) == true) {
return true;
} else {
return _provider.guestCapabilities.contains(capability);
}
}
@override
bool isManualCover() =>
_provider.album.coverProvider is AlbumManualCoverProvider;
@override
Future<Collection?> updatePostLoad(List<CollectionItem> items) async {
final album = await UpdateAlbumWithActualItems(_c.albumRepo)(
account,
_provider.album,
items
.whereType<AlbumAdaptedCollectionItem>()
.map((e) => e.albumItem)
.toList(),
);
if (!identical(album, _provider.album)) {
return CollectionBuilder.byAlbum(account, album);
} else {
return null;
}
}
final DiContainer _c;
final Account account;
final Collection collection;
final CollectionAlbumProvider _provider;
}

View file

@ -0,0 +1,15 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'album.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$CollectionAlbumAdapterNpLog on CollectionAlbumAdapter {
// ignore: unused_element
Logger get _log => log;
static final log =
Logger("entity.collection.adapter.album.CollectionAlbumAdapter");
}

View file

@ -0,0 +1,58 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/adapter.dart';
import 'package:nc_photos/entity/collection/adapter/adapter_mixin.dart';
import 'package:nc_photos/entity/collection/content_provider/location_group.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/collection_item/basic_item.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/list_location_file.dart';
class CollectionLocationGroupAdapter
with
CollectionAdapterReadOnlyTag,
CollectionAdapterUnremovableTag,
CollectionAdapterUnshareableTag
implements CollectionAdapter {
CollectionLocationGroupAdapter(this._c, this.account, this.collection)
: assert(require(_c)),
_provider =
collection.contentProvider as CollectionLocationGroupProvider;
static bool require(DiContainer c) => ListLocationFile.require(c);
@override
Stream<List<CollectionItem>> listItem() async* {
final files = <File>[];
for (final r in account.roots) {
final dir = File(path: file_util.unstripPath(account, r));
files.addAll(await ListLocationFile(_c)(account, dir,
_provider.location.place, _provider.location.countryCode));
}
yield files
.where((f) => file_util.isSupportedFormat(f))
.map((f) => BasicCollectionFileItem(f))
.toList();
}
@override
Future<CollectionItem> adaptToNewItem(CollectionItem original) async {
if (original is CollectionFileItem) {
return BasicCollectionFileItem(original.file);
} else {
throw UnsupportedError("Unsupported type: ${original.runtimeType}");
}
}
@override
bool isPermitted(CollectionCapability capability) =>
_provider.capabilities.contains(capability);
final DiContainer _c;
final Account account;
final Collection collection;
final CollectionLocationGroupProvider _provider;
}

View file

@ -0,0 +1,57 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/adapter.dart';
import 'package:nc_photos/entity/collection/adapter/adapter_mixin.dart';
import 'package:nc_photos/entity/collection/content_provider/memory.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/collection_item/basic_item.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/use_case/list_location_file.dart';
class CollectionMemoryAdapter
with
CollectionAdapterReadOnlyTag,
CollectionAdapterUnremovableTag,
CollectionAdapterUnshareableTag
implements CollectionAdapter {
CollectionMemoryAdapter(this._c, this.account, this.collection)
: assert(require(_c)),
_provider = collection.contentProvider as CollectionMemoryProvider;
static bool require(DiContainer c) => ListLocationFile.require(c);
@override
Stream<List<CollectionItem>> listItem() async* {
final date = DateTime(_provider.year, _provider.month, _provider.day);
final dayRange = _c.pref.getMemoriesRangeOr();
final from = date.subtract(Duration(days: dayRange));
final to = date.add(Duration(days: dayRange + 1));
final files = await FileSqliteDbDataSource(_c).listByDate(
account, from.millisecondsSinceEpoch, to.millisecondsSinceEpoch);
yield files
.where((f) => file_util.isSupportedFormat(f))
.map((f) => BasicCollectionFileItem(f))
.toList();
}
@override
Future<CollectionItem> adaptToNewItem(CollectionItem original) async {
if (original is CollectionFileItem) {
return BasicCollectionFileItem(original.file);
} else {
throw UnsupportedError("Unsupported type: ${original.runtimeType}");
}
}
@override
bool isPermitted(CollectionCapability capability) =>
_provider.capabilities.contains(capability);
final DiContainer _c;
final Account account;
final Collection collection;
final CollectionMemoryProvider _provider;
}

View file

@ -0,0 +1,173 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/adapter.dart';
import 'package:nc_photos/entity/collection/adapter/adapter_mixin.dart';
import 'package:nc_photos/entity/collection/content_provider/nc_album.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/collection_item/basic_item.dart';
import 'package:nc_photos/entity/collection_item/nc_album_item_adapter.dart';
import 'package:nc_photos/entity/collection_item/new_item.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/nc_album.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/use_case/find_file_descriptor.dart';
import 'package:nc_photos/use_case/nc_album/add_file_to_nc_album.dart';
import 'package:nc_photos/use_case/nc_album/edit_nc_album.dart';
import 'package:nc_photos/use_case/nc_album/list_nc_album.dart';
import 'package:nc_photos/use_case/nc_album/list_nc_album_item.dart';
import 'package:nc_photos/use_case/nc_album/remove_from_nc_album.dart';
import 'package:nc_photos/use_case/nc_album/remove_nc_album.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/type.dart';
part 'nc_album.g.dart';
@npLog
class CollectionNcAlbumAdapter
with CollectionAdapterUnshareableTag
implements CollectionAdapter {
CollectionNcAlbumAdapter(this._c, this.account, this.collection)
: assert(require(_c)),
_provider = collection.contentProvider as CollectionNcAlbumProvider;
static bool require(DiContainer c) =>
ListNcAlbumItem.require(c) && FindFileDescriptor.require(c);
@override
Stream<List<CollectionItem>> listItem() {
return ListNcAlbumItem(_c)(account, _provider.album)
.asyncMap((items) async {
final found = await FindFileDescriptor(_c)(
account,
items.map((e) => e.fileId).toList(),
onFileNotFound: (fileId) {
// happens when this is a file shared with you
_log.warning("[listItem] File not found: $fileId");
},
);
return items.map((i) {
final f = found.firstWhereOrNull((e) => e.fdId == i.fileId);
return CollectionFileItemNcAlbumItemAdapter(i, f);
}).toList();
});
}
@override
Future<int> addFiles(
List<FileDescriptor> files, {
ErrorWithValueHandler<FileDescriptor>? onError,
required ValueChanged<Collection> onCollectionUpdated,
}) async {
final count = await AddFileToNcAlbum(_c)(account, _provider.album, files,
onError: onError);
if (count > 0) {
try {
final newAlbum = await _syncRemote();
onCollectionUpdated(collection.copyWith(
contentProvider: _provider.copyWith(
album: newAlbum,
),
));
} catch (e, stackTrace) {
_log.severe("[addFiles] Failed while _syncRemote", e, stackTrace);
}
}
return count;
}
@override
Future<Collection> edit({
String? name,
List<CollectionItem>? items,
CollectionItemSort? itemSort,
OrNull<FileDescriptor>? cover,
List<CollectionItem>? knownItems,
}) async {
assert(name != null);
if (items != null || itemSort != null || cover != null) {
_log.warning(
"[edit] Nextcloud album does not support editing item or sort");
}
final newItems = items?.run((items) => items
.map((e) => e is CollectionFileItem ? e.file : null)
.whereNotNull()
.toList());
final newAlbum = await EditNcAlbum(_c)(
account,
_provider.album,
name: name,
items: newItems,
itemSort: itemSort,
);
return collection.copyWith(
name: name,
contentProvider: _provider.copyWith(album: newAlbum),
);
}
@override
Future<int> removeItems(
List<CollectionItem> items, {
ErrorWithValueIndexedHandler<CollectionItem>? onError,
required ValueChanged<Collection> onCollectionUpdated,
}) async {
final count = await RemoveFromNcAlbum(_c)(account, _provider.album, items,
onError: onError);
if (count > 0) {
try {
final newAlbum = await _syncRemote();
onCollectionUpdated(collection.copyWith(
contentProvider: _provider.copyWith(
album: newAlbum,
),
));
} catch (e, stackTrace) {
_log.severe("[removeItems] Failed while _syncRemote", e, stackTrace);
}
}
return count;
}
@override
Future<CollectionItem> adaptToNewItem(NewCollectionItem original) async {
if (original is NewCollectionFileItem) {
return BasicCollectionFileItem(original.file);
} else {
throw UnsupportedError("Unsupported type: ${original.runtimeType}");
}
}
@override
bool isItemRemovable(CollectionItem item) => true;
@override
Future<void> remove() => RemoveNcAlbum(_c)(account, _provider.album);
@override
bool isPermitted(CollectionCapability capability) =>
_provider.capabilities.contains(capability);
@override
bool isManualCover() => false;
@override
Future<Collection?> updatePostLoad(List<CollectionItem> items) =>
Future.value(null);
Future<NcAlbum> _syncRemote() async {
final remote = await ListNcAlbum(_c)(account).last;
return remote.firstWhere((e) => e.compareIdentity(_provider.album));
}
final DiContainer _c;
final Account account;
final Collection collection;
final CollectionNcAlbumProvider _provider;
}

View file

@ -0,0 +1,15 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'nc_album.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$CollectionNcAlbumAdapterNpLog on CollectionNcAlbumAdapter {
// ignore: unused_element
Logger get _log => log;
static final log =
Logger("entity.collection.adapter.nc_album.CollectionNcAlbumAdapter");
}

View file

@ -0,0 +1,60 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/adapter.dart';
import 'package:nc_photos/entity/collection/adapter/adapter_mixin.dart';
import 'package:nc_photos/entity/collection/content_provider/person.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/collection_item/basic_item.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/list_face.dart';
import 'package:nc_photos/use_case/populate_person.dart';
class CollectionPersonAdapter
with
CollectionAdapterReadOnlyTag,
CollectionAdapterUnremovableTag,
CollectionAdapterUnshareableTag
implements CollectionAdapter {
CollectionPersonAdapter(this._c, this.account, this.collection)
: assert(require(_c)),
_provider = collection.contentProvider as CollectionPersonProvider;
static bool require(DiContainer c) =>
ListFace.require(c) && PopulatePerson.require(c);
@override
Stream<List<CollectionItem>> listItem() async* {
final faces = await ListFace(_c)(account, _provider.person);
final files = await PopulatePerson(_c)(account, faces);
final rootDirs = account.roots
.map((e) => File(path: file_util.unstripPath(account, e)))
.toList();
yield files
.where((f) =>
file_util.isSupportedFormat(f) &&
rootDirs.any((dir) => file_util.isUnderDir(f, dir)))
.map((f) => BasicCollectionFileItem(f))
.toList();
}
@override
Future<CollectionItem> adaptToNewItem(CollectionItem original) async {
if (original is CollectionFileItem) {
return BasicCollectionFileItem(original.file);
} else {
throw UnsupportedError("Unsupported type: ${original.runtimeType}");
}
}
@override
bool isPermitted(CollectionCapability capability) =>
_provider.capabilities.contains(capability);
final DiContainer _c;
final Account account;
final Collection collection;
final CollectionPersonProvider _provider;
}

View file

@ -0,0 +1,47 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/adapter.dart';
import 'package:nc_photos/entity/collection/adapter/adapter_mixin.dart';
import 'package:nc_photos/entity/collection/content_provider/tag.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/collection_item/basic_item.dart';
import 'package:nc_photos/use_case/list_tagged_file.dart';
class CollectionTagAdapter
with
CollectionAdapterReadOnlyTag,
CollectionAdapterUnremovableTag,
CollectionAdapterUnshareableTag
implements CollectionAdapter {
CollectionTagAdapter(this._c, this.account, this.collection)
: assert(require(_c)),
_provider = collection.contentProvider as CollectionTagProvider;
static bool require(DiContainer c) => true;
@override
Stream<List<CollectionItem>> listItem() async* {
final files = await ListTaggedFile(_c)(account, _provider.tags);
yield files.map((f) => BasicCollectionFileItem(f)).toList();
}
@override
Future<CollectionItem> adaptToNewItem(CollectionItem original) async {
if (original is CollectionFileItem) {
return BasicCollectionFileItem(original.file);
} else {
throw UnsupportedError("Unsupported type: ${original.runtimeType}");
}
}
@override
bool isPermitted(CollectionCapability capability) =>
_provider.capabilities.contains(capability);
final DiContainer _c;
final Account account;
final Collection collection;
final CollectionTagProvider _provider;
}

View file

@ -0,0 +1,64 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/content_provider/album.dart';
import 'package:nc_photos/entity/collection/content_provider/location_group.dart';
import 'package:nc_photos/entity/collection/content_provider/nc_album.dart';
import 'package:nc_photos/entity/collection/content_provider/person.dart';
import 'package:nc_photos/entity/collection/content_provider/tag.dart';
import 'package:nc_photos/entity/nc_album.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/use_case/list_location_group.dart';
class CollectionBuilder {
static Collection byAlbum(Account account, Album album) {
return Collection(
name: album.name,
contentProvider: CollectionAlbumProvider(
account: account,
album: album,
),
);
}
static Collection byLocationGroup(Account account, LocationGroup location) {
return Collection(
name: location.place,
contentProvider: CollectionLocationGroupProvider(
account: account,
location: location,
),
);
}
static Collection byNcAlbum(Account account, NcAlbum album) {
return Collection(
name: album.strippedPath,
contentProvider: CollectionNcAlbumProvider(
account: account,
album: album,
),
);
}
static Collection byPerson(Account account, Person person) {
return Collection(
name: person.name,
contentProvider: CollectionPersonProvider(
account: account,
person: person,
),
);
}
static Collection byTags(Account account, List<Tag> tags) {
return Collection(
name: tags.first.displayName,
contentProvider: CollectionTagProvider(
account: account,
tags: tags,
),
);
}
}

View file

@ -0,0 +1,117 @@
import 'package:copy_with/copy_with.dart';
import 'package:equatable/equatable.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/provider.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/util.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util;
import 'package:to_string/to_string.dart';
part 'album.g.dart';
/// Album provided by our app
@genCopyWith
@toString
class CollectionAlbumProvider
with EquatableMixin
implements CollectionContentProvider {
const CollectionAlbumProvider({
required this.account,
required this.album,
});
@override
String toString() => _$toString();
@override
String get fourCc => "ALBM";
@override
String get id => album.albumFile!.fileId!.toString();
@override
int? get count {
if (album.provider is AlbumStaticProvider) {
return (album.provider as AlbumStaticProvider).items.length;
} else {
return null;
}
}
@override
DateTime get lastModified =>
album.provider.latestItemTime ?? album.lastUpdated;
@override
List<CollectionCapability> get capabilities => [
CollectionCapability.sort,
CollectionCapability.rename,
CollectionCapability.manualCover,
if (album.provider is AlbumStaticProvider) ...[
CollectionCapability.manualItem,
CollectionCapability.manualSort,
CollectionCapability.labelItem,
CollectionCapability.share,
],
];
/// Capabilities when this album is shared to this user by someone else
List<CollectionCapability> get guestCapabilities => [
if (album.provider is AlbumStaticProvider) ...[
CollectionCapability.manualItem,
CollectionCapability.labelItem,
],
];
@override
CollectionItemSort get itemSort => album.sortProvider.toCollectionItemSort();
@override
List<CollectionShare> get shares =>
album.shares
?.where((s) => s.userId != account.userId)
.map((s) => CollectionShare(
userId: s.userId,
username: s.displayName ?? s.userId.raw,
))
.toList() ??
const [];
@override
String? getCoverUrl(
int width,
int height, {
bool? isKeepAspectRatio,
}) {
final fd = album.coverProvider.getCover(album);
if (fd == null) {
return null;
} else {
return api_util.getFilePreviewUrlByFileId(
account,
fd.fdId,
width: width,
height: height,
isKeepAspectRatio: isKeepAspectRatio ?? false,
);
}
}
@override
bool get isDynamicCollection => album.provider is! AlbumStaticProvider;
@override
bool get isPendingSharedAlbum =>
album.albumFile?.path.startsWith(
remote_storage_util.getRemotePendingSharedAlbumsDir(account)) ==
true;
@override
List<Object?> get props => [account, album];
final Account account;
final Album album;
}

View file

@ -0,0 +1,48 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'album.dart';
// **************************************************************************
// CopyWithLintRuleGenerator
// **************************************************************************
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class $CollectionAlbumProviderCopyWithWorker {
CollectionAlbumProvider call({Account? account, Album? album});
}
class _$CollectionAlbumProviderCopyWithWorkerImpl
implements $CollectionAlbumProviderCopyWithWorker {
_$CollectionAlbumProviderCopyWithWorkerImpl(this.that);
@override
CollectionAlbumProvider call({dynamic account, dynamic album}) {
return CollectionAlbumProvider(
account: account as Account? ?? that.account,
album: album as Album? ?? that.album);
}
final CollectionAlbumProvider that;
}
extension $CollectionAlbumProviderCopyWith on CollectionAlbumProvider {
$CollectionAlbumProviderCopyWithWorker get copyWith => _$copyWith;
$CollectionAlbumProviderCopyWithWorker get _$copyWith =>
_$CollectionAlbumProviderCopyWithWorkerImpl(this);
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$CollectionAlbumProviderToString on CollectionAlbumProvider {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "CollectionAlbumProvider {account: $account, album: $album}";
}
}

View file

@ -0,0 +1,64 @@
import 'package:equatable/equatable.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/util.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/use_case/list_location_group.dart';
class CollectionLocationGroupProvider
with EquatableMixin
implements CollectionContentProvider {
const CollectionLocationGroupProvider({
required this.account,
required this.location,
});
@override
String get fourCc => "LOCG";
@override
String get id => location.place;
@override
int? get count => location.count;
@override
DateTime get lastModified => location.latestDateTime;
@override
List<CollectionCapability> get capabilities => [];
@override
CollectionItemSort get itemSort => CollectionItemSort.dateDescending;
@override
List<CollectionShare> get shares => [];
@override
String? getCoverUrl(
int width,
int height, {
bool? isKeepAspectRatio,
}) {
return api_util.getFilePreviewUrlByFileId(
account,
location.latestFileId,
width: width,
height: height,
isKeepAspectRatio: isKeepAspectRatio ?? false,
);
}
@override
bool get isDynamicCollection => true;
@override
bool get isPendingSharedAlbum => false;
@override
List<Object?> get props => [account, location];
final Account account;
final LocationGroup location;
}

View file

@ -0,0 +1,78 @@
import 'package:equatable/equatable.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/util.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:to_string/to_string.dart';
part 'memory.g.dart';
@toString
class CollectionMemoryProvider
with EquatableMixin
implements CollectionContentProvider {
const CollectionMemoryProvider({
required this.account,
required this.year,
required this.month,
required this.day,
this.cover,
});
@override
String get fourCc => "MEMY";
@override
String get id => "$year-$month-$day";
@override
int? get count => null;
@override
DateTime get lastModified => DateTime(year, month, day);
@override
List<CollectionCapability> get capabilities => [];
@override
CollectionItemSort get itemSort => CollectionItemSort.dateDescending;
@override
List<CollectionShare> get shares => [];
@override
String? getCoverUrl(
int width,
int height, {
bool? isKeepAspectRatio,
}) {
return cover?.run((cover) => api_util.getFilePreviewUrl(
account,
cover,
width: width,
height: height,
isKeepAspectRatio: isKeepAspectRatio ?? false,
));
}
@override
bool get isDynamicCollection => true;
@override
bool get isPendingSharedAlbum => false;
@override
String toString() => _$toString();
@override
List<Object?> get props => [account, year, month, day, cover];
final Account account;
final int year;
final int month;
final int day;
final FileDescriptor? cover;
}

View file

@ -0,0 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'memory.dart';
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$CollectionMemoryProviderToString on CollectionMemoryProvider {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "CollectionMemoryProvider {account: $account, year: $year, month: $month, day: $day, cover: ${cover == null ? null : "${cover!.fdPath}"}}";
}
}

View file

@ -0,0 +1,87 @@
import 'package:clock/clock.dart';
import 'package:copy_with/copy_with.dart';
import 'package:equatable/equatable.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/util.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/entity/nc_album.dart';
import 'package:to_string/to_string.dart';
part 'nc_album.g.dart';
/// Album provided by our app
@genCopyWith
@toString
class CollectionNcAlbumProvider
with EquatableMixin
implements CollectionContentProvider {
const CollectionNcAlbumProvider({
required this.account,
required this.album,
});
@override
String toString() => _$toString();
@override
String get fourCc => "NC25";
@override
String get id => album.path;
@override
int? get count => album.count;
@override
DateTime get lastModified => album.dateEnd ?? clock.now().toUtc();
@override
List<CollectionCapability> get capabilities => [
CollectionCapability.manualItem,
CollectionCapability.rename,
// CollectionCapability.share,
];
@override
CollectionItemSort get itemSort => CollectionItemSort.dateDescending;
@override
List<CollectionShare> get shares => album.collaborators
.map((c) => CollectionShare(
userId: c.id,
username: c.label,
))
.toList();
@override
String? getCoverUrl(
int width,
int height, {
bool? isKeepAspectRatio,
}) {
if (album.lastPhoto == null) {
return null;
} else {
return api_util.getPhotosApiFilePreviewUrlByFileId(
account,
album.lastPhoto!,
width: width,
height: height,
);
}
}
@override
bool get isDynamicCollection => false;
@override
bool get isPendingSharedAlbum => false;
@override
List<Object?> get props => [account, album];
final Account account;
final NcAlbum album;
}

View file

@ -0,0 +1,48 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'nc_album.dart';
// **************************************************************************
// CopyWithLintRuleGenerator
// **************************************************************************
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class $CollectionNcAlbumProviderCopyWithWorker {
CollectionNcAlbumProvider call({Account? account, NcAlbum? album});
}
class _$CollectionNcAlbumProviderCopyWithWorkerImpl
implements $CollectionNcAlbumProviderCopyWithWorker {
_$CollectionNcAlbumProviderCopyWithWorkerImpl(this.that);
@override
CollectionNcAlbumProvider call({dynamic account, dynamic album}) {
return CollectionNcAlbumProvider(
account: account as Account? ?? that.account,
album: album as NcAlbum? ?? that.album);
}
final CollectionNcAlbumProvider that;
}
extension $CollectionNcAlbumProviderCopyWith on CollectionNcAlbumProvider {
$CollectionNcAlbumProviderCopyWithWorker get copyWith => _$copyWith;
$CollectionNcAlbumProviderCopyWithWorker get _$copyWith =>
_$CollectionNcAlbumProviderCopyWithWorkerImpl(this);
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$CollectionNcAlbumProviderToString on CollectionNcAlbumProvider {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "CollectionNcAlbumProvider {account: $account, album: $album}";
}
}

View file

@ -0,0 +1,62 @@
import 'dart:math' as math;
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/util.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/entity/person.dart';
class CollectionPersonProvider
with EquatableMixin
implements CollectionContentProvider {
const CollectionPersonProvider({
required this.account,
required this.person,
});
@override
String get fourCc => "PERS";
@override
String get id => person.name;
@override
int? get count => person.count;
@override
DateTime get lastModified => clock.now().toUtc();
@override
List<CollectionCapability> get capabilities => [];
@override
CollectionItemSort get itemSort => CollectionItemSort.dateDescending;
@override
List<CollectionShare> get shares => [];
@override
String? getCoverUrl(
int width,
int height, {
bool? isKeepAspectRatio,
}) {
return api_util.getFacePreviewUrl(account, person.thumbFaceId,
size: math.max(width, height));
}
@override
bool get isDynamicCollection => true;
@override
bool get isPendingSharedAlbum => false;
@override
List<Object?> get props => [account, person];
final Account account;
final Person person;
}

View file

@ -0,0 +1,57 @@
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/util.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/entity/tag.dart';
class CollectionTagProvider
with EquatableMixin
implements CollectionContentProvider {
CollectionTagProvider({
required this.account,
required this.tags,
}) : assert(tags.isNotEmpty);
@override
String get fourCc => "TAG-";
@override
String get id => tags.first.displayName;
@override
int? get count => null;
@override
DateTime get lastModified => clock.now().toUtc();
@override
List<CollectionCapability> get capabilities => [];
@override
CollectionItemSort get itemSort => CollectionItemSort.dateDescending;
@override
List<CollectionShare> get shares => [];
@override
String? getCoverUrl(
int width,
int height, {
bool? isKeepAspectRatio,
}) =>
null;
@override
bool get isDynamicCollection => true;
@override
bool get isPendingSharedAlbum => false;
@override
List<Object?> get props => [account, tags];
final Account account;
final List<Tag> tags;
}

View file

@ -0,0 +1,108 @@
import 'package:clock/clock.dart';
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/controller/collections_controller.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/item.dart';
import 'package:nc_photos/entity/album/provider.dart';
import 'package:nc_photos/entity/album/sort_provider.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection/content_provider/album.dart';
import 'package:nc_photos/entity/collection/content_provider/nc_album.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/nc_album.dart';
import 'package:nc_photos/use_case/find_file.dart';
import 'package:np_codegen/np_codegen.dart';
part 'exporter.g.dart';
@npLog
class CollectionExporter {
const CollectionExporter(this.account, this.collectionsController,
this.collection, this.items, this.exportName);
/// Export as a new collection backed by our client side album
Future<Collection> asAlbum() async {
final files = await FindFile(KiwiContainer().resolve<DiContainer>())(
account,
items.whereType<CollectionFileItem>().map((e) => e.file.fdId).toList(),
onFileNotFound: (fileId) {
_log.severe("[asAlbum] File not found: $fileId");
},
);
final newAlbum = Album(
name: exportName,
provider: AlbumStaticProvider(
items: items
.map((e) {
if (e is CollectionFileItem) {
final f = files
.firstWhereOrNull((f) => f.compareServerIdentity(e.file));
if (f == null) {
return null;
} else {
return AlbumFileItem(
addedBy: account.userId,
addedAt: clock.now().toUtc(),
file: f,
);
}
} else if (e is CollectionLabelItem) {
return AlbumLabelItem(
addedBy: account.userId,
addedAt: clock.now().toUtc(),
text: e.text,
);
} else {
return null;
}
})
.whereNotNull()
.toList(),
latestItemTime: collection.lastModified,
),
coverProvider: const AlbumAutoCoverProvider(),
sortProvider: const AlbumTimeSortProvider(isAscending: false),
);
var newCollection = Collection(
name: exportName,
contentProvider: CollectionAlbumProvider(
account: account,
album: newAlbum,
),
);
return await collectionsController.createNew(newCollection);
}
/// Export as a new collection backed by Nextcloud album
Future<Collection> asNcAlbum() async {
var newCollection = Collection(
name: exportName,
contentProvider: CollectionNcAlbumProvider(
account: account,
album: NcAlbum.createNew(account: account, name: exportName),
),
);
newCollection = await collectionsController.createNew(newCollection);
// only files are supported in NcAlbum
final newFiles =
items.whereType<CollectionFileItem>().map((e) => e.file).toList();
final data = collectionsController
.peekStream()
.data
.firstWhere((e) => e.collection.compareIdentity(newCollection));
await data.controller.addFiles(newFiles);
return newCollection;
}
final Account account;
final CollectionsController collectionsController;
final Collection collection;
final List<CollectionItem> items;
final String exportName;
}

View file

@ -0,0 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'exporter.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$CollectionExporterNpLog on CollectionExporter {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("entity.collection.exporter.CollectionExporter");
}

View file

@ -0,0 +1,70 @@
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:np_common/ci_string.dart';
import 'package:to_string/to_string.dart';
import 'package:tuple/tuple.dart';
part 'util.g.dart';
enum CollectionSort {
dateDescending,
dateAscending,
nameAscending,
nameDescending;
bool isAscending() {
return this == CollectionSort.dateAscending ||
this == CollectionSort.nameAscending;
}
}
@toString
class CollectionShare with EquatableMixin {
const CollectionShare({
required this.userId,
required this.username,
});
@override
String toString() => _$toString();
@override
List<Object?> get props => [userId, username];
final CiString userId;
final String username;
}
enum CollectionShareResult {
ok,
partial,
}
extension CollectionListExtension on Iterable<Collection> {
List<Collection> sortedBy(CollectionSort by) {
return map<Tuple2<Comparable, Collection>>((e) {
switch (by) {
case CollectionSort.nameAscending:
case CollectionSort.nameDescending:
return Tuple2(e.name.toLowerCase(), e);
case CollectionSort.dateAscending:
case CollectionSort.dateDescending:
return Tuple2(e.contentProvider.lastModified, e);
}
})
.sorted((a, b) {
final x = by.isAscending() ? a : b;
final y = by.isAscending() ? b : a;
final tmp = x.item1.compareTo(y.item1);
if (tmp != 0) {
return tmp;
} else {
return x.item2.name.compareTo(y.item2.name);
}
})
.map((e) => e.item2)
.toList();
}
}

View file

@ -0,0 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'util.dart';
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$CollectionShareToString on CollectionShare {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "CollectionShare {userId: $userId, username: $username}";
}
}

View file

@ -0,0 +1,22 @@
import 'package:nc_photos/entity/file_descriptor.dart';
/// An item in a [Collection]
abstract class CollectionItem {
const CollectionItem();
}
abstract class CollectionFileItem implements CollectionItem {
const CollectionFileItem();
FileDescriptor get file;
}
abstract class CollectionLabelItem implements CollectionItem {
const CollectionLabelItem();
/// An object used to identify this instance
///
/// [id] should be unique and stable
Object get id;
String get text;
}

View file

@ -0,0 +1,57 @@
import 'package:nc_photos/entity/album/item.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:to_string/to_string.dart';
part 'album_item_adapter.g.dart';
mixin AlbumAdaptedCollectionItem on CollectionItem {
static AlbumAdaptedCollectionItem fromItem(AlbumItem item) {
if (item is AlbumFileItem) {
return CollectionFileItemAlbumAdapter(item);
} else if (item is AlbumLabelItem) {
return CollectionLabelItemAlbumAdapter(item);
} else {
throw ArgumentError("Unknown type: ${item.runtimeType}");
}
}
AlbumItem get albumItem;
}
@toString
class CollectionFileItemAlbumAdapter extends CollectionFileItem
with AlbumAdaptedCollectionItem {
const CollectionFileItemAlbumAdapter(this.item);
@override
String toString() => _$toString();
@override
FileDescriptor get file => item.file;
@override
AlbumItem get albumItem => item;
final AlbumFileItem item;
}
@toString
class CollectionLabelItemAlbumAdapter extends CollectionLabelItem
with AlbumAdaptedCollectionItem {
const CollectionLabelItemAlbumAdapter(this.item);
@override
String toString() => _$toString();
@override
Object get id => item.addedAt;
@override
String get text => item.text;
@override
AlbumItem get albumItem => item;
final AlbumLabelItem item;
}

View file

@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'album_item_adapter.dart';
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$CollectionFileItemAlbumAdapterToString
on CollectionFileItemAlbumAdapter {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "CollectionFileItemAlbumAdapter {item: $item}";
}
}
extension _$CollectionLabelItemAlbumAdapterToString
on CollectionLabelItemAlbumAdapter {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "CollectionLabelItemAlbumAdapter {item: $item}";
}
}

View file

@ -0,0 +1,17 @@
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:to_string/to_string.dart';
part 'basic_item.g.dart';
/// The basic form of [CollectionFileItem]
@toString
class BasicCollectionFileItem implements CollectionFileItem {
const BasicCollectionFileItem(this.file);
@override
String toString() => _$toString();
@override
final FileDescriptor file;
}

View file

@ -0,0 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'basic_item.dart';
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$BasicCollectionFileItemToString on BasicCollectionFileItem {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "BasicCollectionFileItem {file: ${file.fdPath}}";
}
}

View file

@ -0,0 +1,20 @@
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/nc_album_item.dart';
import 'package:to_string/to_string.dart';
part 'nc_album_item_adapter.g.dart';
@toString
class CollectionFileItemNcAlbumItemAdapter extends CollectionFileItem {
const CollectionFileItemNcAlbumItemAdapter(this.item, [this.localFile]);
@override
String toString() => _$toString();
@override
FileDescriptor get file => localFile ?? item.toFile();
final NcAlbumItem item;
final FileDescriptor? localFile;
}

View file

@ -0,0 +1,15 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'nc_album_item_adapter.dart';
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$CollectionFileItemNcAlbumItemAdapterToString
on CollectionFileItemNcAlbumItemAdapter {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "CollectionFileItemNcAlbumItemAdapter {item: $item, localFile: ${localFile == null ? null : "${localFile!.fdPath}"}}";
}
}

View file

@ -0,0 +1,42 @@
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:to_string/to_string.dart';
part 'new_item.g.dart';
abstract class NewCollectionItem implements CollectionItem {}
/// A new [CollectionFileItem]
///
/// This class is for marking an intermediate item that has recently been added
/// but not necessarily persisted yet to the provider of this collection
@toString
class NewCollectionFileItem implements CollectionFileItem, NewCollectionItem {
const NewCollectionFileItem(this.file);
@override
String toString() => _$toString();
@override
final FileDescriptor file;
}
/// A new [CollectionLabelItem]
///
/// This class is for marking an intermediate item that has recently been added
/// but not necessarily persisted yet to the provider of this collection
@toString
class NewCollectionLabelItem implements CollectionLabelItem, NewCollectionItem {
const NewCollectionLabelItem(this.text, this.createdAt);
@override
String toString() => _$toString();
@override
Object get id => createdAt;
@override
final String text;
final DateTime createdAt;
}

View file

@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'new_item.dart';
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$NewCollectionFileItemToString on NewCollectionFileItem {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "NewCollectionFileItem {file: ${file.fdPath}}";
}
}
extension _$NewCollectionLabelItemToString on NewCollectionLabelItem {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "NewCollectionLabelItem {text: $text, createdAt: $createdAt}";
}
}

View file

@ -0,0 +1,148 @@
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/entity/album/sort_provider.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:tuple/tuple.dart';
part 'sorter.g.dart';
abstract class CollectionSorter {
const CollectionSorter();
static CollectionSorter fromSortType(CollectionItemSort type) {
switch (type) {
case CollectionItemSort.dateDescending:
return const CollectionTimeSorter(isAscending: false);
case CollectionItemSort.dateAscending:
return const CollectionTimeSorter(isAscending: true);
case CollectionItemSort.nameAscending:
return const CollectionFilenameSorter(isAscending: true);
case CollectionItemSort.nameDescending:
return const CollectionFilenameSorter(isAscending: false);
case CollectionItemSort.manual:
return const CollectionNullSorter();
}
}
/// Return a sorted copy of [items]
List<CollectionItem> call(List<CollectionItem> items);
}
/// Sort provider that does nothing
class CollectionNullSorter implements CollectionSorter {
const CollectionNullSorter();
@override
List<CollectionItem> call(List<CollectionItem> items) {
return List.of(items);
}
}
/// Sort based on the time of the files
class CollectionTimeSorter implements CollectionSorter {
const CollectionTimeSorter({
required this.isAscending,
});
@override
List<CollectionItem> call(List<CollectionItem> items) {
DateTime? prevFileTime;
return items
.map((e) {
if (e is CollectionFileItem) {
// take the file time
prevFileTime = e.file.fdDateTime;
}
// for non file items, use the sibling file's time
return Tuple2(prevFileTime, e);
})
.stableSorted((x, y) {
if (x.item1 == null && y.item1 == null) {
return 0;
} else if (x.item1 == null) {
return -1;
} else if (y.item1 == null) {
return 1;
} else {
if (isAscending) {
return x.item1!.compareTo(y.item1!);
} else {
return y.item1!.compareTo(x.item1!);
}
}
})
.map((e) => e.item2)
.toList();
}
final bool isAscending;
}
/// Sort based on the name of the files
class CollectionFilenameSorter implements CollectionSorter {
const CollectionFilenameSorter({
required this.isAscending,
});
@override
List<CollectionItem> call(List<CollectionItem> items) {
String? prevFilename;
return items
.map((e) {
if (e is CollectionFileItem) {
// take the file name
prevFilename = e.file.filename;
}
// for non file items, use the sibling file's name
return Tuple2(prevFilename, e);
})
.stableSorted((x, y) {
if (x.item1 == null && y.item1 == null) {
return 0;
} else if (x.item1 == null) {
return -1;
} else if (y.item1 == null) {
return 1;
} else {
if (isAscending) {
return compareNatural(x.item1!, y.item1!);
} else {
return compareNatural(y.item1!, x.item1!);
}
}
})
.map((e) => e.item2)
.toList();
}
final bool isAscending;
}
@npLog
class CollectionAlbumSortAdapter implements CollectionSorter {
const CollectionAlbumSortAdapter(this.sort);
@override
List<CollectionItem> call(List<CollectionItem> items) {
final CollectionSorter sorter;
if (sort is AlbumNullSortProvider) {
sorter = const CollectionNullSorter();
} else if (sort is AlbumTimeSortProvider) {
sorter = CollectionTimeSorter(
isAscending: (sort as AlbumTimeSortProvider).isAscending);
} else if (sort is AlbumFilenameSortProvider) {
sorter = CollectionFilenameSorter(
isAscending: (sort as AlbumFilenameSortProvider).isAscending);
} else {
_log.shout("[call] Unknown type: ${sort.runtimeType}");
throw UnsupportedError("Unknown type: ${sort.runtimeType}");
}
return sorter(items);
}
final AlbumSortProvider sort;
}

View file

@ -0,0 +1,15 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sorter.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$CollectionAlbumSortAdapterNpLog on CollectionAlbumSortAdapter {
// ignore: unused_element
Logger get _log => log;
static final log =
Logger("entity.collection_item.sorter.CollectionAlbumSortAdapter");
}

View file

@ -0,0 +1,7 @@
enum CollectionItemSort {
dateDescending,
dateAscending,
nameAscending,
nameDescending,
manual;
}

View file

@ -390,7 +390,6 @@ class File with EquatableMixin implements FileDescriptor {
@override
String toString() => _$toString();
@override
JsonObj toJson() {
return {
"path": path,
@ -419,6 +418,9 @@ class File with EquatableMixin implements FileDescriptor {
};
}
@override
JsonObj toFdJson() => FileDescriptor.toJson(this);
File copyWith({
String? path,
int? contentLength,
@ -537,6 +539,15 @@ extension FileExtension on File {
);
bool isOwned(CiString userId) => ownerId == null || ownerId == userId;
FileDescriptor toDescriptor() => FileDescriptor(
fdPath: path,
fdId: fileId!,
fdMime: contentType,
fdIsArchived: isArchived ?? false,
fdIsFavorite: isFavorite ?? false,
fdDateTime: bestDateTime,
);
}
class FileServerIdentityComparator {
@ -575,7 +586,7 @@ class FileRepo {
dataSrc.listMinimal(account, dir);
/// See [FileDataSource.remove]
Future<void> remove(Account account, File file) =>
Future<void> remove(Account account, FileDescriptor file) =>
dataSrc.remove(account, file);
/// See [FileDataSource.getBinary]
@ -659,7 +670,7 @@ abstract class FileDataSource {
Future<List<File>> listMinimal(Account account, File dir);
/// Remove file
Future<void> remove(Account account, File f);
Future<void> remove(Account account, FileDescriptor f);
/// Read file as binary array
Future<Uint8List> getBinary(Account account, File f);

View file

@ -90,10 +90,10 @@ class FileWebdavDataSource implements FileDataSource {
}
@override
remove(Account account, File f) async {
_log.info("[remove] ${f.path}");
remove(Account account, FileDescriptor f) async {
_log.info("[remove] ${f.fdPath}");
final response =
await ApiUtil.fromAccount(account).files().delete(path: f.path);
await ApiUtil.fromAccount(account).files().delete(path: f.fdPath);
if (!response.isGood) {
_log.severe("[remove] Failed requesting server: $response");
throw ApiException(
@ -435,8 +435,8 @@ class FileSqliteDbDataSource implements FileDataSource {
}
@override
remove(Account account, File f) {
_log.info("[remove] ${f.path}");
remove(Account account, FileDescriptor f) {
_log.info("[remove] ${f.fdPath}");
return FileSqliteCacheRemover(_c)(account, f);
}
@ -549,13 +549,20 @@ class FileSqliteDbDataSource implements FileDataSource {
}
@override
move(
Future<void> move(
Account account,
File f,
String destination, {
bool? shouldOverwrite,
}) async {
// do nothing
_log.info("[move] ${f.path} to $destination");
await _c.sqliteDb.use((db) async {
await db.moveFileByFileId(
sql.ByAccount.app(account),
f.fileId!,
File(path: destination).strippedPathWithEmpty,
);
});
}
@override
@ -719,7 +726,7 @@ class FileCachedDataSource implements FileDataSource {
}
@override
remove(Account account, File f) async {
remove(Account account, FileDescriptor f) async {
await _remoteSrc.remove(account, f);
try {
await _sqliteDbSrc.remove(account, f);
@ -786,7 +793,7 @@ class FileCachedDataSource implements FileDataSource {
}
@override
move(
Future<void> move(
Account account,
File f,
String destination, {
@ -794,6 +801,13 @@ class FileCachedDataSource implements FileDataSource {
}) async {
await _remoteSrc.move(account, f, destination,
shouldOverwrite: shouldOverwrite);
try {
await _sqliteDbSrc.move(account, f, destination);
} catch (e, stackTrace) {
// ignore cache failure
_log.warning(
"Failed while move: ${logFilename(f.strippedPath)}", e, stackTrace);
}
}
@override

View file

@ -191,9 +191,7 @@ class FileSqliteCacheUpdater {
) async {
// query list of rowIds for files in [remoteFiles]
final rowIds = await db.accountFileRowIdsByFileIds(
remoteFiles.map((f) => f.fileId!),
sqlAccount: dbAccount,
);
sql.ByAccount.sql(dbAccount), remoteFiles.map((f) => f.fileId!));
final rowIdsMap = Map.fromEntries(rowIds.map((e) => MapEntry(e.fileId, e)));
final inserts = <sql.CompleteFileCompanion>[];
@ -359,7 +357,7 @@ class FileSqliteCacheRemover {
static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb);
/// Remove a file/dir from cache
Future<void> call(Account account, File f) async {
Future<void> call(Account account, FileDescriptor f) async {
await _c.sqliteDb.use((db) async {
final dbAccount = await db.accountOf(account);
final rowIds = await db.accountFileRowIdsOf(f, sqlAccount: dbAccount);

View file

@ -1,6 +1,10 @@
import 'package:equatable/equatable.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:np_common/type.dart';
import 'package:path/path.dart' as path_lib;
import 'package:to_string/to_string.dart';
part 'file_descriptor.g.dart';
int compareFileDescriptorDateTimeDescending(
FileDescriptor x, FileDescriptor y) {
@ -13,6 +17,7 @@ int compareFileDescriptorDateTimeDescending(
}
}
@toString
class FileDescriptor with EquatableMixin {
const FileDescriptor({
required this.fdPath,
@ -29,18 +34,23 @@ class FileDescriptor with EquatableMixin {
fdMime: json["fdMime"],
fdIsArchived: json["fdIsArchived"],
fdIsFavorite: json["fdIsFavorite"],
fdDateTime: json["fdDateTime"],
fdDateTime: DateTime.parse(json["fdDateTime"]),
);
JsonObj toJson() => {
"fdPath": fdPath,
"fdId": fdId,
"fdMime": fdMime,
"fdIsArchived": fdIsArchived,
"fdIsFavorite": fdIsFavorite,
"fdDateTime": fdDateTime,
static JsonObj toJson(FileDescriptor that) => {
"fdPath": that.fdPath,
"fdId": that.fdId,
"fdMime": that.fdMime,
"fdIsArchived": that.fdIsArchived,
"fdIsFavorite": that.fdIsFavorite,
"fdDateTime": that.fdDateTime.toUtc().toIso8601String(),
};
@override
String toString() => _$toString();
JsonObj toFdJson() => toJson(this);
@override
get props => [
fdPath,
@ -107,4 +117,14 @@ extension FileDescriptorExtension on FileDescriptor {
/// hashCode to be used with [compareServerIdentity]
int get identityHashCode => fdId.hashCode;
File toFile() {
return File(
path: fdPath,
fileId: fdId,
contentType: fdMime,
isArchived: fdIsArchived,
isFavorite: fdIsFavorite,
);
}
}

View file

@ -0,0 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'file_descriptor.dart';
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$FileDescriptorToString on FileDescriptor {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "FileDescriptor {fdPath: $fdPath, fdId: $fdId, fdMime: $fdMime, fdIsArchived: $fdIsArchived, fdIsFavorite: $fdIsFavorite, fdDateTime: $fdDateTime}";
}
}

View file

@ -5,6 +5,7 @@ import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util;
import 'package:np_api/np_api.dart' as api;
import 'package:np_common/ci_string.dart';
import 'package:np_common/string_extension.dart';
import 'package:path/path.dart' as path_lib;
@ -20,8 +21,11 @@ bool isSupportedImageMime(String mime) =>
bool isSupportedImageFormat(FileDescriptor file) =>
isSupportedImageMime(file.fdMime ?? "");
bool isSupportedVideoMime(String mime) =>
supportedVideoFormatMimes.contains(mime);
bool isSupportedVideoFormat(FileDescriptor file) =>
isSupportedFormat(file) && file.fdMime?.startsWith("video/") == true;
isSupportedVideoMime(file.fdMime ?? "");
bool isMetadataSupportedMime(String mime) =>
_metadataSupportedFormatMimes.contains(mime);
@ -32,21 +36,25 @@ bool isMetadataSupportedFormat(FileDescriptor file) =>
bool isTrash(Account account, FileDescriptor file) =>
file.fdPath.startsWith(api_util.getTrashbinPath(account));
bool isAlbumFile(Account account, File file) =>
file.path.startsWith(remote_storage_util.getRemoteAlbumsDir(account));
bool isAlbumFile(Account account, FileDescriptor file) =>
file.fdPath.startsWith(remote_storage_util.getRemoteAlbumsDir(account));
bool isNcAlbumFile(Account account, FileDescriptor file) =>
file.fdPath.startsWith("${api.ApiPhotos.path}/");
/// Return if [file] is located under [dir]
///
/// Return false if [file] is [dir] itself (since it's not "under")
///
/// See [isOrUnderDir]
bool isUnderDir(File file, File dir) => file.path.startsWith("${dir.path}/");
bool isUnderDir(FileDescriptor file, FileDescriptor dir) =>
file.fdPath.startsWith("${dir.fdPath}/");
/// Return if [file] is [dir] or located under [dir]
///
/// See [isUnderDir]
bool isOrUnderDir(File file, File dir) =>
file.path == dir.path || isUnderDir(file, dir);
bool isOrUnderDir(FileDescriptor file, FileDescriptor dir) =>
file.fdPath == dir.fdPath || isUnderDir(file, dir);
/// Convert a stripped path to a full path
///
@ -118,6 +126,9 @@ final supportedFormatMimes = [
final supportedImageFormatMimes =
supportedFormatMimes.where((f) => f.startsWith("image/")).toList();
final supportedVideoFormatMimes =
supportedFormatMimes.where((f) => f.startsWith("video/")).toList();
const _metadataSupportedFormatMimes = [
"image/jpeg",
"image/heic",

View file

@ -0,0 +1,142 @@
import 'package:copy_with/copy_with.dart';
import 'package:equatable/equatable.dart';
import 'package:nc_photos/account.dart';
import 'package:np_api/np_api.dart' as api;
import 'package:np_common/ci_string.dart';
import 'package:np_common/string_extension.dart';
import 'package:np_common/type.dart';
import 'package:to_string/to_string.dart';
part 'nc_album.g.dart';
/// Server-side album since Nextcloud 25
@toString
@genCopyWith
class NcAlbum with EquatableMixin {
NcAlbum({
required String path,
required this.lastPhoto,
required this.nbItems,
required this.location,
required this.dateStart,
required this.dateEnd,
required this.collaborators,
}) : path = path.trimAny("/");
static NcAlbum createNew({
required Account account,
required String name,
}) {
return NcAlbum(
path: "${api.ApiPhotos.path}/${account.userId}/albums/$name",
lastPhoto: null,
nbItems: 0,
location: null,
dateStart: null,
dateEnd: null,
collaborators: const [],
);
}
@override
String toString() => _$toString();
@override
List<Object?> get props => [
path,
lastPhoto,
nbItems,
location,
dateStart,
dateEnd,
collaborators,
];
final String path;
/// File ID of the last photo
///
/// The API will return -1 if there's no photos in the album. It's mapped to
/// null here instead
final int? lastPhoto;
/// Items count
final int nbItems;
final String? location;
final DateTime? dateStart;
final DateTime? dateEnd;
final List<NcAlbumCollaborator> collaborators;
}
extension NcAlbumExtension on NcAlbum {
/// Return the path of this file with the DAV part stripped
///
/// WebDAV file path: remote.php/dav/photos/{userId}/albums/{strippedPath}.
/// If this path points to the user's root album path, return "."
String get strippedPath {
if (!path.startsWith("remote.php/dav/photos/")) {
return path;
}
var begin = "remote.php/dav/photos/".length;
begin = path.indexOf("/", begin);
if (begin == -1) {
return path;
}
if (path.slice(begin, begin + 7) != "/albums") {
return path;
}
// /albums/
begin += 8;
final stripped = path.slice(begin);
if (stripped.isEmpty) {
return ".";
} else {
return stripped;
}
}
String getRenamedPath(String newName) {
final i = path.indexOf("albums/");
if (i == -1) {
throw StateError("Invalid path: $path");
}
return "${path.substring(0, i + "albums/".length)}$newName";
}
int get count => nbItems;
bool compareIdentity(NcAlbum other) {
return path == other.path;
}
int get identityHashCode => path.hashCode;
}
@toString
class NcAlbumCollaborator {
const NcAlbumCollaborator({
required this.id,
required this.label,
required this.type,
});
factory NcAlbumCollaborator.fromJson(JsonObj json) => NcAlbumCollaborator(
id: CiString(json["id"]),
label: json["label"],
type: json["type"],
);
JsonObj toJson() => {
"id": id.raw,
"label": label,
"type": type,
};
@override
String toString() => _$toString();
final CiString id;
final String label;
// right now it's unclear what this variable represents
final int type;
}

Some files were not shown because too many files have changed in this diff Show more