Rewrite collection support and add NC25 album support

This commit is contained in:
Ming Ming 2023-04-13 23:32:31 +08:00
parent 7c798a024d
commit f3901b5ff0
215 changed files with 12187 additions and 5480 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

View file

@ -4,6 +4,7 @@ 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/person.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/sharee.dart';
@ -94,6 +95,22 @@ class ApiFileConverter {
static final _log = _$ApiFileConverterNpLog.log;
}
class ApiNcAlbumConverter {
static NcAlbum fromApi(api.NcAlbum album) {
return NcAlbum(
path: 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: const [],
);
}
}
class ApiPersonConverter {
static Person fromApi(api.Person person) {
return Person(

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) {

View file

@ -4,12 +4,13 @@ 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/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 +25,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 +126,7 @@ class HomeSearchSuggestionBlocFailure extends HomeSearchSuggestionBlocState {
@npLog
class HomeSearchSuggestionBloc
extends Bloc<HomeSearchSuggestionBlocEvent, HomeSearchSuggestionBlocState> {
HomeSearchSuggestionBloc(this.account)
HomeSearchSuggestionBloc(this.account, this.collectionsController)
: super(const HomeSearchSuggestionBlocInit()) {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
@ -187,13 +188,19 @@ 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)(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 +246,7 @@ class HomeSearchSuggestionBloc
}
final Account account;
final CollectionsController collectionsController;
late final DiContainer _c;
final _search = Woozy<_Searcheable>(limit: 10);
@ -249,16 +257,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,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,23 @@
import 'package:kiwi/kiwi.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/controller/collections_controller.dart';
import 'package:nc_photos/di_container.dart';
class AccountController {
void setCurrentAccount(Account account) {
_account = account;
_collectionsController?.dispose();
_collectionsController = null;
}
Account get account => _account!;
CollectionsController get collectionsController =>
_collectionsController ??= CollectionsController(
KiwiContainer().resolve<DiContainer>(),
account: _account!,
);
Account? _account;
CollectionsController? _collectionsController;
}

View file

@ -0,0 +1,336 @@
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/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;
}
/// 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());
}
});
}
/// 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());
}
});
}
/// 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());
}
});
}
/// 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,
));
}
} 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,274 @@
import 'dart:async';
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/di_container.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection_item.dart';
import 'package:nc_photos/entity/collection_item/util.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/list_collection.dart';
import 'package:nc_photos/use_case/collection/remove_collections.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,
});
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;
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),
], shouldRemoveCache: false),
));
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,
}) async {
try {
final c = await _mutex.protect(() async {
return await EditCollection(_c)(
account,
collection,
name: name,
items: items,
itemSort: itemSort,
);
});
_updateCollection(c, items);
} catch (e, stackTrace) {
_dataStreamController.addError(e, stackTrace);
}
}
Future<void> _load() async {
var lastData = const CollectionStreamEvent(
data: [],
hasNext: false,
);
final completer = Completer();
ListCollection(_c)(account).listen(
(c) {
lastData = CollectionStreamEvent(
data: _prepareDataFor(c, shouldRemoveCache: true),
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, {
required bool shouldRemoveCache,
}) {
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;
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

@ -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

@ -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),
);
} 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");
}
final albums = await const AlbumRemoteDataSource2().getAlbums(
account,
[albumFile],
onError: (_, error, stackTrace) {
Error.throwWithStackTrace(error, stackTrace ?? StackTrace.current);
},
);
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}");
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,
};
},
);
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 {
yield ExceptionEvent(v["error"], v["stackTrace"]);
}
} else {
if (!fileIdMap.containsKey(f.file.fileId)) {
fileIdMap[f.file.fileId] = {
"file": f,
"album": s.album,
};
}
if (s.share != null) {
(fileIdMap[f.file.fileId]!["shares"] ??= <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();
} 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 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,247 @@
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"]);
return sql.SqliteAlbumConverter.fromSql(
item["album"], 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

@ -0,0 +1,150 @@
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) => e.albumFile!) ?? 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,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,93 @@
import 'package:copy_with/copy_with.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 {
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.getCoverUrl]
String? getCoverUrl(int width, int height) =>
contentProvider.getCoverUrl(width, height);
CollectionSorter getSorter() => CollectionSorter.fromSortType(itemSort);
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,
}
/// Provide the actual content of a collection
abstract class CollectionContentProvider {
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
List<CollectionCapability> get capabilities;
/// Return the sort type
CollectionItemSort get itemSort;
/// 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
String? getCoverUrl(int width, int height);
}

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,83 @@
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/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/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_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: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 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
///
/// [name] and [items] are optional params and if not null, set the value to
/// this collection
Future<Collection> edit({
String? name,
List<CollectionItem>? items,
CollectionItemSort? itemSort,
});
/// Remove [items] from this collection and return the removed count
Future<int> removeItems(
List<CollectionItem> items, {
ErrorWithValueIndexedHandler<CollectionItem>? onError,
required ValueChanged<Collection> onCollectionUpdated,
});
/// Convert a [NewCollectionItem] to an adapted one
Future<CollectionItem> adaptToNewItem(NewCollectionItem original);
bool isItemsRemovable(List<CollectionItem> items);
/// Remove this collection
Future<void> remove();
}
abstract class CollectionItemAdapter {
const CollectionItemAdapter();
CollectionItem toItem();
}

View file

@ -0,0 +1,207 @@
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/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_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/iterable_extension.dart';
import 'package:nc_photos/object_extension.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/preprocess_album.dart';
import 'package:np_codegen/np_codegen.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,
}) async {
assert(name != null || items != null || itemSort != 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,
);
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<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 isItemsRemovable(List<CollectionItem> items) {
if (_provider.album.albumFile!.isOwned(account.userId)) {
return true;
}
return items
.whereType<AlbumAdaptedCollectionItem>()
.any((e) => e.albumItem.addedBy == account.userId);
}
@override
Future<void> remove() => RemoveAlbum(_c)(account, _provider.album);
final DiContainer _c;
final Account account;
final Collection collection;
final CollectionAlbumProvider _provider;
}
// class CollectionAlbumItemAdapter implements CollectionItemAdapter {}

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,56 @@
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/read_only_adapter.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 CollectionReadOnlyAdapter
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
Future<void> remove() {
throw UnsupportedError("Operation not supported");
}
final DiContainer _c;
final Account account;
final Collection collection;
final CollectionLocationGroupProvider _provider;
}

View file

@ -0,0 +1,151 @@
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/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/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/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 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) => FindFileDescriptor(_c)(
account,
items.map((e) => e.fileId).toList(),
onFileNotFound: (fileId) {
_log.severe("[listItem] File not found: $fileId");
},
))
.map((files) => files.map(BasicCollectionFileItem.new).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,
}) async {
assert(name != null);
if (items != null || itemSort != 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 isItemsRemovable(List<CollectionItem> items) {
return true;
}
@override
Future<void> remove() => RemoveNcAlbum(_c)(account, _provider.album);
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,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/read_only_adapter.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 CollectionReadOnlyAdapter
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
Future<void> remove() {
throw UnsupportedError("Operation not supported");
}
final DiContainer _c;
final Account account;
final Collection collection;
final CollectionPersonProvider _provider;
}

View file

@ -0,0 +1,42 @@
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_item.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:np_common/type.dart';
/// A read-only collection that does not support modifying its items
mixin CollectionReadOnlyAdapter 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,
}) {
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 isItemsRemovable(List<CollectionItem> items) {
return false;
}
}

View file

@ -0,0 +1,45 @@
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/read_only_adapter.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 CollectionReadOnlyAdapter
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
Future<void> remove() {
throw UnsupportedError("Operation not supported");
}
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,75 @@
import 'package:copy_with/copy_with.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_item/util.dart';
import 'package:to_string/to_string.dart';
part 'album.g.dart';
/// Album provided by our app
@genCopyWith
@toString
class CollectionAlbumProvider 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 => [
if (album.provider is AlbumStaticProvider) ...[
CollectionCapability.manualItem,
CollectionCapability.sort,
CollectionCapability.manualSort,
CollectionCapability.rename,
CollectionCapability.labelItem,
],
];
@override
CollectionItemSort get itemSort => album.sortProvider.toCollectionItemSort();
@override
String? getCoverUrl(int width, int height) {
final fd = album.coverProvider.getCover(album);
if (fd == null) {
return null;
} else {
return api_util.getFilePreviewUrlByFileId(
account,
fd.fdId,
width: width,
height: height,
isKeepAspectRatio: false,
);
}
}
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,44 @@
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_item/util.dart';
import 'package:nc_photos/use_case/list_location_group.dart';
class CollectionLocationGroupProvider 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
String? getCoverUrl(int width, int height) {
return api_util.getFilePreviewUrlByFileId(
account,
location.latestFileId,
width: width,
height: height,
isKeepAspectRatio: false,
);
}
final Account account;
final LocationGroup location;
}

View file

@ -0,0 +1,62 @@
import 'package:clock/clock.dart';
import 'package:copy_with/copy_with.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_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 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,
];
@override
CollectionItemSort get itemSort => CollectionItemSort.dateDescending;
@override
String? getCoverUrl(int width, int height) {
if (album.lastPhoto == null) {
return null;
} else {
return api_util.getFilePreviewUrlByFileId(
account,
album.lastPhoto!,
width: width,
height: height,
isKeepAspectRatio: false,
);
}
}
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,42 @@
import 'dart:math' as math;
import 'package:clock/clock.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_item/util.dart';
import 'package:nc_photos/entity/person.dart';
class CollectionPersonProvider 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
String? getCoverUrl(int width, int height) {
return api_util.getFacePreviewUrl(account, person.thumbFaceId,
size: math.max(width, height));
}
final Account account;
final Person person;
}

View file

@ -0,0 +1,36 @@
import 'package:clock/clock.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/collection_item/util.dart';
import 'package:nc_photos/entity/tag.dart';
class CollectionTagProvider 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
String? getCoverUrl(int width, int height) => null;
final Account account;
final List<Tag> tags;
}

View file

@ -0,0 +1,43 @@
import 'package:collection/collection.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:tuple/tuple.dart';
enum CollectionSort {
dateDescending,
dateAscending,
nameAscending,
nameDescending;
bool isAscending() {
return this == CollectionSort.dateAscending ||
this == CollectionSort.nameAscending;
}
}
extension CollectionListExtension on List<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,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,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

@ -575,7 +575,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 +659,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);
}
@ -719,7 +719,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);

View file

@ -359,7 +359,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,4 +1,5 @@
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;
@ -107,4 +108,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

@ -20,8 +20,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 +35,22 @@ 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));
/// 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 +122,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,100 @@
import 'package:copy_with/copy_with.dart';
import 'package:nc_photos/account.dart';
import 'package:np_common/string_extension.dart';
import 'package:to_string/to_string.dart';
part 'nc_album.g.dart';
/// Server-side album since Nextcloud 25
@toString
@genCopyWith
class NcAlbum {
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: "remote.php/dav/photos/${account.userId}/albums/$name",
lastPhoto: null,
nbItems: 0,
location: null,
dateStart: null,
dateEnd: null,
collaborators: const [],
);
}
@override
String toString() => _$toString();
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;
}
class NcAlbumCollaborator {}

View file

@ -0,0 +1,69 @@
// 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 $NcAlbumCopyWithWorker {
NcAlbum call(
{String? path,
int? lastPhoto,
int? nbItems,
String? location,
DateTime? dateStart,
DateTime? dateEnd,
List<NcAlbumCollaborator>? collaborators});
}
class _$NcAlbumCopyWithWorkerImpl implements $NcAlbumCopyWithWorker {
_$NcAlbumCopyWithWorkerImpl(this.that);
@override
NcAlbum call(
{dynamic path,
dynamic lastPhoto = copyWithNull,
dynamic nbItems,
dynamic location = copyWithNull,
dynamic dateStart = copyWithNull,
dynamic dateEnd = copyWithNull,
dynamic collaborators}) {
return NcAlbum(
path: path as String? ?? that.path,
lastPhoto:
lastPhoto == copyWithNull ? that.lastPhoto : lastPhoto as int?,
nbItems: nbItems as int? ?? that.nbItems,
location:
location == copyWithNull ? that.location : location as String?,
dateStart:
dateStart == copyWithNull ? that.dateStart : dateStart as DateTime?,
dateEnd: dateEnd == copyWithNull ? that.dateEnd : dateEnd as DateTime?,
collaborators:
collaborators as List<NcAlbumCollaborator>? ?? that.collaborators);
}
final NcAlbum that;
}
extension $NcAlbumCopyWith on NcAlbum {
$NcAlbumCopyWithWorker get copyWith => _$copyWith;
$NcAlbumCopyWithWorker get _$copyWith => _$NcAlbumCopyWithWorkerImpl(this);
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$NcAlbumToString on NcAlbum {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "NcAlbum {path: $path, lastPhoto: $lastPhoto, nbItems: $nbItems, location: $location, dateStart: $dateStart, dateEnd: $dateEnd, collaborators: [length: ${collaborators.length}]}";
}
}

View file

@ -0,0 +1,241 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/entity_converter.dart';
import 'package:nc_photos/entity/nc_album.dart';
import 'package:nc_photos/entity/nc_album/item.dart';
import 'package:nc_photos/entity/nc_album/repo.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/list_util.dart' as list_util;
import 'package:nc_photos/np_api_util.dart';
import 'package:np_api/np_api.dart' as api;
import 'package:np_codegen/np_codegen.dart';
part 'data_source.g.dart';
@npLog
class NcAlbumRemoteDataSource implements NcAlbumDataSource {
const NcAlbumRemoteDataSource();
@override
Future<List<NcAlbum>> getAlbums(Account account) async {
_log.info("[getAlbums] account: ${account.userId}");
final response = await ApiUtil.fromAccount(account)
.photos(account.userId.toString())
.albums()
.propfind(
lastPhoto: 1,
nbItems: 1,
location: 1,
dateRange: 1,
collaborators: 1,
);
if (!response.isGood) {
_log.severe("[getAlbums] Failed requesting server: $response");
throw ApiException(
response: response,
message: "Server responed with an error: HTTP ${response.statusCode}",
);
}
final apiNcAlbums = await api.NcAlbumParser().parse(response.body);
return apiNcAlbums
.map(ApiNcAlbumConverter.fromApi)
.where((a) => a.strippedPath != ".")
.toList();
}
@override
Future<void> create(Account account, NcAlbum album) async {
_log.info("[create] account: ${account.userId}, album: ${album.path}");
final response = await ApiUtil.fromAccount(account)
.photos(account.userId.toString())
.album(album.strippedPath)
.mkcol();
if (!response.isGood) {
_log.severe("[create] Failed requesting server: $response");
throw ApiException(
response: response,
message: "Server responed with an error: HTTP ${response.statusCode}",
);
}
}
@override
Future<void> remove(Account account, NcAlbum album) async {
_log.info("[remove] account: ${account.userId}, album: ${album.path}");
final response = await ApiUtil.fromAccount(account)
.photos(account.userId.toString())
.album(album.strippedPath)
.delete();
if (!response.isGood) {
_log.severe("[remove] Failed requesting server: $response");
throw ApiException(
response: response,
message: "Server responed with an error: HTTP ${response.statusCode}",
);
}
}
@override
Future<List<NcAlbumItem>> getItems(Account account, NcAlbum album) async {
_log.info(
"[getItems] account: ${account.userId}, album: ${album.strippedPath}");
final response = await ApiUtil.fromAccount(account).files().propfind(
path: album.path,
fileid: 1,
);
if (!response.isGood) {
_log.severe("[getItems] Failed requesting server: $response");
throw ApiException(
response: response,
message: "Server responed with an error: HTTP ${response.statusCode}",
);
}
final apiFiles = await api.FileParser().parse(response.body);
return apiFiles
.where((f) => f.fileId != null)
.map(ApiFileConverter.fromApi)
.map((f) => NcAlbumItem(f.fileId!))
.toList();
}
}
@npLog
class NcAlbumSqliteDbDataSource implements NcAlbumCacheDataSource {
const NcAlbumSqliteDbDataSource(this.sqliteDb);
@override
Future<List<NcAlbum>> getAlbums(Account account) async {
_log.info("[getAlbums] account: ${account.userId}");
final dbAlbums = await sqliteDb.use((db) async {
return await db.ncAlbumsByAccount(account: sql.ByAccount.app(account));
});
return dbAlbums
.map((a) {
try {
return SqliteNcAlbumConverter.fromSql(account.userId.toString(), a);
} catch (e, stackTrace) {
_log.severe(
"[getAlbums] Failed while converting DB entry", e, stackTrace);
return null;
}
})
.whereNotNull()
.toList();
}
@override
Future<void> create(Account account, NcAlbum album) async {
_log.info("[create] account: ${account.userId}, album: ${album.path}");
await sqliteDb.use((db) async {
await db.insertNcAlbum(
account: sql.ByAccount.app(account),
object: SqliteNcAlbumConverter.toSql(null, album),
);
});
}
@override
Future<void> remove(Account account, NcAlbum album) async {
_log.info("[remove] account: ${account.userId}, album: ${album.path}");
await sqliteDb.use((db) async {
await db.deleteNcAlbumByRelativePath(
account: sql.ByAccount.app(account),
relativePath: album.strippedPath,
);
});
}
@override
Future<List<NcAlbumItem>> getItems(Account account, NcAlbum album) async {
_log.info(
"[getItems] account: ${account.userId}, album: ${album.strippedPath}");
final dbItems = await sqliteDb.use((db) async {
return await db.ncAlbumItemsByParentRelativePath(
account: sql.ByAccount.app(account),
parentRelativePath: album.strippedPath,
);
});
return dbItems.map((i) => NcAlbumItem(i.fileId)).toList();
}
@override
Future<void> updateAlbumsCache(Account account, List<NcAlbum> remote) async {
await sqliteDb.use((db) async {
final dbAccount = await db.accountOf(account);
final existings = (await db.partialNcAlbumsByAccount(
account: sql.ByAccount.sql(dbAccount),
columns: [db.ncAlbums.rowId, db.ncAlbums.relativePath],
))
.whereNotNull()
.toList();
await db.batch((batch) async {
for (final r in remote) {
final dbObj = SqliteNcAlbumConverter.toSql(dbAccount, r);
final found = existings.indexWhere((e) => e[1] == r.strippedPath);
if (found != -1) {
// existing record, update it
batch.update(
db.ncAlbums,
dbObj,
where: (sql.$NcAlbumsTable t) =>
t.rowId.equals(existings[found][0]),
);
} else {
// insert
batch.insert(db.ncAlbums, dbObj);
}
}
for (final e in existings
.where((e) => !remote.any((r) => r.strippedPath == e[1]))) {
batch.deleteWhere(
db.ncAlbums,
(sql.$NcAlbumsTable t) => t.rowId.equals(e[0]),
);
}
});
});
}
@override
Future<void> updateItemsCache(
Account account, NcAlbum album, List<NcAlbumItem> remote) async {
await sqliteDb.use((db) async {
final dbAlbum = await db.ncAlbumByRelativePath(
account: sql.ByAccount.app(account),
relativePath: album.strippedPath,
);
final existingItems = await db.ncAlbumItemsByParent(
parent: dbAlbum!,
);
final idDiff = list_util.diff(
existingItems.map((e) => e.fileId).sorted((a, b) => a.compareTo(b)),
remote.map((e) => e.fileId).sorted((a, b) => a.compareTo(b)),
);
if (idDiff.onlyInA.isNotEmpty || idDiff.onlyInB.isNotEmpty) {
await db.batch((batch) async {
for (final id in idDiff.onlyInB) {
// new
batch.insert(
db.ncAlbumItems,
SqliteNcAlbumItemConverter.toSql(dbAlbum, id),
);
}
// removed
batch.deleteWhere(
db.ncAlbumItems,
(sql.$NcAlbumItemsTable t) =>
t.parent.equals(dbAlbum.rowId) & t.fileId.isIn(idDiff.onlyInA),
);
});
}
});
}
final sql.SqliteDb sqliteDb;
}

View file

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

View file

@ -0,0 +1,5 @@
class NcAlbumItem {
const NcAlbumItem(this.fileId);
final int fileId;
}

View file

@ -0,0 +1,140 @@
import 'dart:async';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/nc_album.dart';
import 'package:nc_photos/entity/nc_album/item.dart';
import 'package:np_codegen/np_codegen.dart';
part 'repo.g.dart';
abstract class NcAlbumRepo {
/// Query all [NcAlbum]s belonging to [account]
///
/// Normally the stream should complete with only a single event, but some
/// implementation might want to return multiple set of values, say one set of
/// cached value and later another set of updated value from a remote source.
/// In any case, each event is guaranteed to be one complete set of data
Stream<List<NcAlbum>> getAlbums(Account account);
/// Create a new [album]
Future<void> create(Account account, NcAlbum album);
/// Remove [album]
Future<void> remove(Account account, NcAlbum album);
/// Query all items belonging to [album]
Stream<List<NcAlbumItem>> getItems(Account account, NcAlbum album);
}
/// A repo that simply relay the call to the backed [NcAlbumDataSource]
@npLog
class BasicNcAlbumRepo implements NcAlbumRepo {
const BasicNcAlbumRepo(this.dataSrc);
@override
Stream<List<NcAlbum>> getAlbums(Account account) async* {
yield await dataSrc.getAlbums(account);
}
@override
Future<void> create(Account account, NcAlbum album) =>
dataSrc.create(account, album);
@override
Future<void> remove(Account account, NcAlbum album) =>
dataSrc.remove(account, album);
@override
Stream<List<NcAlbumItem>> getItems(Account account, NcAlbum album) async* {
yield await dataSrc.getItems(account, album);
}
final NcAlbumDataSource dataSrc;
}
/// A repo that manage a remote data source and a cache data source
@npLog
class CachedNcAlbumRepo implements NcAlbumRepo {
const CachedNcAlbumRepo(this.remoteDataSrc, this.cacheDataSrc);
@override
Stream<List<NcAlbum>> getAlbums(Account account) async* {
// get cache
try {
yield await cacheDataSrc.getAlbums(account);
} catch (e, stackTrace) {
_log.shout("[getAlbums] Cache failure", e, stackTrace);
}
// query remote
final remote = await remoteDataSrc.getAlbums(account);
yield remote;
// update cache
unawaited(cacheDataSrc.updateAlbumsCache(account, remote));
}
@override
Future<void> create(Account account, NcAlbum album) async {
await remoteDataSrc.create(account, album);
try {
await cacheDataSrc.create(account, album);
} catch (e, stackTrace) {
_log.warning("[create] Failed to insert cache", e, stackTrace);
}
}
@override
Future<void> remove(Account account, NcAlbum album) async {
await remoteDataSrc.remove(account, album);
try {
await cacheDataSrc.remove(account, album);
} catch (e, stackTrace) {
_log.warning("[remove] Failed to remove cache", e, stackTrace);
}
}
@override
Stream<List<NcAlbumItem>> getItems(Account account, NcAlbum album) async* {
// get cache
try {
yield await cacheDataSrc.getItems(account, album);
} catch (e, stackTrace) {
_log.shout("[getItems] Cache failure", e, stackTrace);
}
// query remote
final remote = await remoteDataSrc.getItems(account, album);
yield remote;
// update cache
await cacheDataSrc.updateItemsCache(account, album, remote);
}
final NcAlbumDataSource remoteDataSrc;
final NcAlbumCacheDataSource cacheDataSrc;
}
abstract class NcAlbumDataSource {
/// Query all [NcAlbum]s belonging to [account]
Future<List<NcAlbum>> getAlbums(Account account);
/// Create a new [album]
Future<void> create(Account account, NcAlbum album);
/// Remove [album]
Future<void> remove(Account account, NcAlbum album);
/// Query all items belonging to [album]
Future<List<NcAlbumItem>> getItems(Account account, NcAlbum album);
}
abstract class NcAlbumCacheDataSource extends NcAlbumDataSource {
/// Update cache to match [remote]
Future<void> updateAlbumsCache(Account account, List<NcAlbum> remote);
/// Update cache to match [remote]
Future<void> updateItemsCache(
Account account, NcAlbum album, List<NcAlbumItem> remote);
}

View file

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

View file

@ -2,7 +2,7 @@ import 'package:drift/drift.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart' as app;
import 'package:nc_photos/entity/file.dart' as app;
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/file_descriptor.dart' as app;
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/sqlite/files_query_builder.dart';
import 'package:nc_photos/entity/sqlite/isolate_util.dart';
@ -19,6 +19,7 @@ import 'package:np_codegen/np_codegen.dart';
part 'database.g.dart';
part 'database_extension.dart';
part 'database/nc_album_extension.dart';
// remember to also update the truncate method after adding a new table
@npLog
@ -36,6 +37,8 @@ part 'database_extension.dart';
AlbumShares,
Tags,
Persons,
NcAlbums,
NcAlbumItems,
],
)
class SqliteDb extends _$SqliteDb {

View file

@ -4157,6 +4157,639 @@ class $PersonsTable extends Persons with TableInfo<$PersonsTable, Person> {
}
}
class NcAlbum extends DataClass implements Insertable<NcAlbum> {
final int rowId;
final int account;
final String relativePath;
final int? lastPhoto;
final int nbItems;
final String? location;
final DateTime? dateStart;
final DateTime? dateEnd;
NcAlbum(
{required this.rowId,
required this.account,
required this.relativePath,
this.lastPhoto,
required this.nbItems,
this.location,
this.dateStart,
this.dateEnd});
factory NcAlbum.fromData(Map<String, dynamic> data, {String? prefix}) {
final effectivePrefix = prefix ?? '';
return NcAlbum(
rowId: const IntType()
.mapFromDatabaseResponse(data['${effectivePrefix}row_id'])!,
account: const IntType()
.mapFromDatabaseResponse(data['${effectivePrefix}account'])!,
relativePath: const StringType()
.mapFromDatabaseResponse(data['${effectivePrefix}relative_path'])!,
lastPhoto: const IntType()
.mapFromDatabaseResponse(data['${effectivePrefix}last_photo']),
nbItems: const IntType()
.mapFromDatabaseResponse(data['${effectivePrefix}nb_items'])!,
location: const StringType()
.mapFromDatabaseResponse(data['${effectivePrefix}location']),
dateStart: $NcAlbumsTable.$converter0.mapToDart(const DateTimeType()
.mapFromDatabaseResponse(data['${effectivePrefix}date_start'])),
dateEnd: $NcAlbumsTable.$converter1.mapToDart(const DateTimeType()
.mapFromDatabaseResponse(data['${effectivePrefix}date_end'])),
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['row_id'] = Variable<int>(rowId);
map['account'] = Variable<int>(account);
map['relative_path'] = Variable<String>(relativePath);
if (!nullToAbsent || lastPhoto != null) {
map['last_photo'] = Variable<int?>(lastPhoto);
}
map['nb_items'] = Variable<int>(nbItems);
if (!nullToAbsent || location != null) {
map['location'] = Variable<String?>(location);
}
if (!nullToAbsent || dateStart != null) {
final converter = $NcAlbumsTable.$converter0;
map['date_start'] = Variable<DateTime?>(converter.mapToSql(dateStart));
}
if (!nullToAbsent || dateEnd != null) {
final converter = $NcAlbumsTable.$converter1;
map['date_end'] = Variable<DateTime?>(converter.mapToSql(dateEnd));
}
return map;
}
NcAlbumsCompanion toCompanion(bool nullToAbsent) {
return NcAlbumsCompanion(
rowId: Value(rowId),
account: Value(account),
relativePath: Value(relativePath),
lastPhoto: lastPhoto == null && nullToAbsent
? const Value.absent()
: Value(lastPhoto),
nbItems: Value(nbItems),
location: location == null && nullToAbsent
? const Value.absent()
: Value(location),
dateStart: dateStart == null && nullToAbsent
? const Value.absent()
: Value(dateStart),
dateEnd: dateEnd == null && nullToAbsent
? const Value.absent()
: Value(dateEnd),
);
}
factory NcAlbum.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return NcAlbum(
rowId: serializer.fromJson<int>(json['rowId']),
account: serializer.fromJson<int>(json['account']),
relativePath: serializer.fromJson<String>(json['relativePath']),
lastPhoto: serializer.fromJson<int?>(json['lastPhoto']),
nbItems: serializer.fromJson<int>(json['nbItems']),
location: serializer.fromJson<String?>(json['location']),
dateStart: serializer.fromJson<DateTime?>(json['dateStart']),
dateEnd: serializer.fromJson<DateTime?>(json['dateEnd']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'rowId': serializer.toJson<int>(rowId),
'account': serializer.toJson<int>(account),
'relativePath': serializer.toJson<String>(relativePath),
'lastPhoto': serializer.toJson<int?>(lastPhoto),
'nbItems': serializer.toJson<int>(nbItems),
'location': serializer.toJson<String?>(location),
'dateStart': serializer.toJson<DateTime?>(dateStart),
'dateEnd': serializer.toJson<DateTime?>(dateEnd),
};
}
NcAlbum copyWith(
{int? rowId,
int? account,
String? relativePath,
Value<int?> lastPhoto = const Value.absent(),
int? nbItems,
Value<String?> location = const Value.absent(),
Value<DateTime?> dateStart = const Value.absent(),
Value<DateTime?> dateEnd = const Value.absent()}) =>
NcAlbum(
rowId: rowId ?? this.rowId,
account: account ?? this.account,
relativePath: relativePath ?? this.relativePath,
lastPhoto: lastPhoto.present ? lastPhoto.value : this.lastPhoto,
nbItems: nbItems ?? this.nbItems,
location: location.present ? location.value : this.location,
dateStart: dateStart.present ? dateStart.value : this.dateStart,
dateEnd: dateEnd.present ? dateEnd.value : this.dateEnd,
);
@override
String toString() {
return (StringBuffer('NcAlbum(')
..write('rowId: $rowId, ')
..write('account: $account, ')
..write('relativePath: $relativePath, ')
..write('lastPhoto: $lastPhoto, ')
..write('nbItems: $nbItems, ')
..write('location: $location, ')
..write('dateStart: $dateStart, ')
..write('dateEnd: $dateEnd')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(rowId, account, relativePath, lastPhoto,
nbItems, location, dateStart, dateEnd);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is NcAlbum &&
other.rowId == this.rowId &&
other.account == this.account &&
other.relativePath == this.relativePath &&
other.lastPhoto == this.lastPhoto &&
other.nbItems == this.nbItems &&
other.location == this.location &&
other.dateStart == this.dateStart &&
other.dateEnd == this.dateEnd);
}
class NcAlbumsCompanion extends UpdateCompanion<NcAlbum> {
final Value<int> rowId;
final Value<int> account;
final Value<String> relativePath;
final Value<int?> lastPhoto;
final Value<int> nbItems;
final Value<String?> location;
final Value<DateTime?> dateStart;
final Value<DateTime?> dateEnd;
const NcAlbumsCompanion({
this.rowId = const Value.absent(),
this.account = const Value.absent(),
this.relativePath = const Value.absent(),
this.lastPhoto = const Value.absent(),
this.nbItems = const Value.absent(),
this.location = const Value.absent(),
this.dateStart = const Value.absent(),
this.dateEnd = const Value.absent(),
});
NcAlbumsCompanion.insert({
this.rowId = const Value.absent(),
required int account,
required String relativePath,
this.lastPhoto = const Value.absent(),
required int nbItems,
this.location = const Value.absent(),
this.dateStart = const Value.absent(),
this.dateEnd = const Value.absent(),
}) : account = Value(account),
relativePath = Value(relativePath),
nbItems = Value(nbItems);
static Insertable<NcAlbum> custom({
Expression<int>? rowId,
Expression<int>? account,
Expression<String>? relativePath,
Expression<int?>? lastPhoto,
Expression<int>? nbItems,
Expression<String?>? location,
Expression<DateTime?>? dateStart,
Expression<DateTime?>? dateEnd,
}) {
return RawValuesInsertable({
if (rowId != null) 'row_id': rowId,
if (account != null) 'account': account,
if (relativePath != null) 'relative_path': relativePath,
if (lastPhoto != null) 'last_photo': lastPhoto,
if (nbItems != null) 'nb_items': nbItems,
if (location != null) 'location': location,
if (dateStart != null) 'date_start': dateStart,
if (dateEnd != null) 'date_end': dateEnd,
});
}
NcAlbumsCompanion copyWith(
{Value<int>? rowId,
Value<int>? account,
Value<String>? relativePath,
Value<int?>? lastPhoto,
Value<int>? nbItems,
Value<String?>? location,
Value<DateTime?>? dateStart,
Value<DateTime?>? dateEnd}) {
return NcAlbumsCompanion(
rowId: rowId ?? this.rowId,
account: account ?? this.account,
relativePath: relativePath ?? this.relativePath,
lastPhoto: lastPhoto ?? this.lastPhoto,
nbItems: nbItems ?? this.nbItems,
location: location ?? this.location,
dateStart: dateStart ?? this.dateStart,
dateEnd: dateEnd ?? this.dateEnd,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (rowId.present) {
map['row_id'] = Variable<int>(rowId.value);
}
if (account.present) {
map['account'] = Variable<int>(account.value);
}
if (relativePath.present) {
map['relative_path'] = Variable<String>(relativePath.value);
}
if (lastPhoto.present) {
map['last_photo'] = Variable<int?>(lastPhoto.value);
}
if (nbItems.present) {
map['nb_items'] = Variable<int>(nbItems.value);
}
if (location.present) {
map['location'] = Variable<String?>(location.value);
}
if (dateStart.present) {
final converter = $NcAlbumsTable.$converter0;
map['date_start'] =
Variable<DateTime?>(converter.mapToSql(dateStart.value));
}
if (dateEnd.present) {
final converter = $NcAlbumsTable.$converter1;
map['date_end'] = Variable<DateTime?>(converter.mapToSql(dateEnd.value));
}
return map;
}
@override
String toString() {
return (StringBuffer('NcAlbumsCompanion(')
..write('rowId: $rowId, ')
..write('account: $account, ')
..write('relativePath: $relativePath, ')
..write('lastPhoto: $lastPhoto, ')
..write('nbItems: $nbItems, ')
..write('location: $location, ')
..write('dateStart: $dateStart, ')
..write('dateEnd: $dateEnd')
..write(')'))
.toString();
}
}
class $NcAlbumsTable extends NcAlbums with TableInfo<$NcAlbumsTable, NcAlbum> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$NcAlbumsTable(this.attachedDatabase, [this._alias]);
final VerificationMeta _rowIdMeta = const VerificationMeta('rowId');
@override
late final GeneratedColumn<int?> rowId = GeneratedColumn<int?>(
'row_id', aliasedName, false,
type: const IntType(),
requiredDuringInsert: false,
defaultConstraints: 'PRIMARY KEY AUTOINCREMENT');
final VerificationMeta _accountMeta = const VerificationMeta('account');
@override
late final GeneratedColumn<int?> account = GeneratedColumn<int?>(
'account', aliasedName, false,
type: const IntType(),
requiredDuringInsert: true,
defaultConstraints: 'REFERENCES accounts (row_id) ON DELETE CASCADE');
final VerificationMeta _relativePathMeta =
const VerificationMeta('relativePath');
@override
late final GeneratedColumn<String?> relativePath = GeneratedColumn<String?>(
'relative_path', aliasedName, false,
type: const StringType(), requiredDuringInsert: true);
final VerificationMeta _lastPhotoMeta = const VerificationMeta('lastPhoto');
@override
late final GeneratedColumn<int?> lastPhoto = GeneratedColumn<int?>(
'last_photo', aliasedName, true,
type: const IntType(), requiredDuringInsert: false);
final VerificationMeta _nbItemsMeta = const VerificationMeta('nbItems');
@override
late final GeneratedColumn<int?> nbItems = GeneratedColumn<int?>(
'nb_items', aliasedName, false,
type: const IntType(), requiredDuringInsert: true);
final VerificationMeta _locationMeta = const VerificationMeta('location');
@override
late final GeneratedColumn<String?> location = GeneratedColumn<String?>(
'location', aliasedName, true,
type: const StringType(), requiredDuringInsert: false);
final VerificationMeta _dateStartMeta = const VerificationMeta('dateStart');
@override
late final GeneratedColumnWithTypeConverter<DateTime, DateTime?> dateStart =
GeneratedColumn<DateTime?>('date_start', aliasedName, true,
type: const IntType(), requiredDuringInsert: false)
.withConverter<DateTime>($NcAlbumsTable.$converter0);
final VerificationMeta _dateEndMeta = const VerificationMeta('dateEnd');
@override
late final GeneratedColumnWithTypeConverter<DateTime, DateTime?> dateEnd =
GeneratedColumn<DateTime?>('date_end', aliasedName, true,
type: const IntType(), requiredDuringInsert: false)
.withConverter<DateTime>($NcAlbumsTable.$converter1);
@override
List<GeneratedColumn> get $columns => [
rowId,
account,
relativePath,
lastPhoto,
nbItems,
location,
dateStart,
dateEnd
];
@override
String get aliasedName => _alias ?? 'nc_albums';
@override
String get actualTableName => 'nc_albums';
@override
VerificationContext validateIntegrity(Insertable<NcAlbum> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('row_id')) {
context.handle(
_rowIdMeta, rowId.isAcceptableOrUnknown(data['row_id']!, _rowIdMeta));
}
if (data.containsKey('account')) {
context.handle(_accountMeta,
account.isAcceptableOrUnknown(data['account']!, _accountMeta));
} else if (isInserting) {
context.missing(_accountMeta);
}
if (data.containsKey('relative_path')) {
context.handle(
_relativePathMeta,
relativePath.isAcceptableOrUnknown(
data['relative_path']!, _relativePathMeta));
} else if (isInserting) {
context.missing(_relativePathMeta);
}
if (data.containsKey('last_photo')) {
context.handle(_lastPhotoMeta,
lastPhoto.isAcceptableOrUnknown(data['last_photo']!, _lastPhotoMeta));
}
if (data.containsKey('nb_items')) {
context.handle(_nbItemsMeta,
nbItems.isAcceptableOrUnknown(data['nb_items']!, _nbItemsMeta));
} else if (isInserting) {
context.missing(_nbItemsMeta);
}
if (data.containsKey('location')) {
context.handle(_locationMeta,
location.isAcceptableOrUnknown(data['location']!, _locationMeta));
}
context.handle(_dateStartMeta, const VerificationResult.success());
context.handle(_dateEndMeta, const VerificationResult.success());
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {rowId};
@override
List<Set<GeneratedColumn>> get uniqueKeys => [
{account, relativePath},
];
@override
NcAlbum map(Map<String, dynamic> data, {String? tablePrefix}) {
return NcAlbum.fromData(data,
prefix: tablePrefix != null ? '$tablePrefix.' : null);
}
@override
$NcAlbumsTable createAlias(String alias) {
return $NcAlbumsTable(attachedDatabase, alias);
}
static TypeConverter<DateTime, DateTime> $converter0 =
const SqliteDateTimeConverter();
static TypeConverter<DateTime, DateTime> $converter1 =
const SqliteDateTimeConverter();
}
class NcAlbumItem extends DataClass implements Insertable<NcAlbumItem> {
final int rowId;
final int parent;
final int fileId;
NcAlbumItem(
{required this.rowId, required this.parent, required this.fileId});
factory NcAlbumItem.fromData(Map<String, dynamic> data, {String? prefix}) {
final effectivePrefix = prefix ?? '';
return NcAlbumItem(
rowId: const IntType()
.mapFromDatabaseResponse(data['${effectivePrefix}row_id'])!,
parent: const IntType()
.mapFromDatabaseResponse(data['${effectivePrefix}parent'])!,
fileId: const IntType()
.mapFromDatabaseResponse(data['${effectivePrefix}file_id'])!,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['row_id'] = Variable<int>(rowId);
map['parent'] = Variable<int>(parent);
map['file_id'] = Variable<int>(fileId);
return map;
}
NcAlbumItemsCompanion toCompanion(bool nullToAbsent) {
return NcAlbumItemsCompanion(
rowId: Value(rowId),
parent: Value(parent),
fileId: Value(fileId),
);
}
factory NcAlbumItem.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return NcAlbumItem(
rowId: serializer.fromJson<int>(json['rowId']),
parent: serializer.fromJson<int>(json['parent']),
fileId: serializer.fromJson<int>(json['fileId']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'rowId': serializer.toJson<int>(rowId),
'parent': serializer.toJson<int>(parent),
'fileId': serializer.toJson<int>(fileId),
};
}
NcAlbumItem copyWith({int? rowId, int? parent, int? fileId}) => NcAlbumItem(
rowId: rowId ?? this.rowId,
parent: parent ?? this.parent,
fileId: fileId ?? this.fileId,
);
@override
String toString() {
return (StringBuffer('NcAlbumItem(')
..write('rowId: $rowId, ')
..write('parent: $parent, ')
..write('fileId: $fileId')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(rowId, parent, fileId);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is NcAlbumItem &&
other.rowId == this.rowId &&
other.parent == this.parent &&
other.fileId == this.fileId);
}
class NcAlbumItemsCompanion extends UpdateCompanion<NcAlbumItem> {
final Value<int> rowId;
final Value<int> parent;
final Value<int> fileId;
const NcAlbumItemsCompanion({
this.rowId = const Value.absent(),
this.parent = const Value.absent(),
this.fileId = const Value.absent(),
});
NcAlbumItemsCompanion.insert({
this.rowId = const Value.absent(),
required int parent,
required int fileId,
}) : parent = Value(parent),
fileId = Value(fileId);
static Insertable<NcAlbumItem> custom({
Expression<int>? rowId,
Expression<int>? parent,
Expression<int>? fileId,
}) {
return RawValuesInsertable({
if (rowId != null) 'row_id': rowId,
if (parent != null) 'parent': parent,
if (fileId != null) 'file_id': fileId,
});
}
NcAlbumItemsCompanion copyWith(
{Value<int>? rowId, Value<int>? parent, Value<int>? fileId}) {
return NcAlbumItemsCompanion(
rowId: rowId ?? this.rowId,
parent: parent ?? this.parent,
fileId: fileId ?? this.fileId,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (rowId.present) {
map['row_id'] = Variable<int>(rowId.value);
}
if (parent.present) {
map['parent'] = Variable<int>(parent.value);
}
if (fileId.present) {
map['file_id'] = Variable<int>(fileId.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('NcAlbumItemsCompanion(')
..write('rowId: $rowId, ')
..write('parent: $parent, ')
..write('fileId: $fileId')
..write(')'))
.toString();
}
}
class $NcAlbumItemsTable extends NcAlbumItems
with TableInfo<$NcAlbumItemsTable, NcAlbumItem> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$NcAlbumItemsTable(this.attachedDatabase, [this._alias]);
final VerificationMeta _rowIdMeta = const VerificationMeta('rowId');
@override
late final GeneratedColumn<int?> rowId = GeneratedColumn<int?>(
'row_id', aliasedName, false,
type: const IntType(),
requiredDuringInsert: false,
defaultConstraints: 'PRIMARY KEY AUTOINCREMENT');
final VerificationMeta _parentMeta = const VerificationMeta('parent');
@override
late final GeneratedColumn<int?> parent = GeneratedColumn<int?>(
'parent', aliasedName, false,
type: const IntType(),
requiredDuringInsert: true,
defaultConstraints: 'REFERENCES nc_albums (row_id) ON DELETE CASCADE');
final VerificationMeta _fileIdMeta = const VerificationMeta('fileId');
@override
late final GeneratedColumn<int?> fileId = GeneratedColumn<int?>(
'file_id', aliasedName, false,
type: const IntType(), requiredDuringInsert: true);
@override
List<GeneratedColumn> get $columns => [rowId, parent, fileId];
@override
String get aliasedName => _alias ?? 'nc_album_items';
@override
String get actualTableName => 'nc_album_items';
@override
VerificationContext validateIntegrity(Insertable<NcAlbumItem> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('row_id')) {
context.handle(
_rowIdMeta, rowId.isAcceptableOrUnknown(data['row_id']!, _rowIdMeta));
}
if (data.containsKey('parent')) {
context.handle(_parentMeta,
parent.isAcceptableOrUnknown(data['parent']!, _parentMeta));
} else if (isInserting) {
context.missing(_parentMeta);
}
if (data.containsKey('file_id')) {
context.handle(_fileIdMeta,
fileId.isAcceptableOrUnknown(data['file_id']!, _fileIdMeta));
} else if (isInserting) {
context.missing(_fileIdMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {rowId};
@override
List<Set<GeneratedColumn>> get uniqueKeys => [
{parent, fileId},
];
@override
NcAlbumItem map(Map<String, dynamic> data, {String? tablePrefix}) {
return NcAlbumItem.fromData(data,
prefix: tablePrefix != null ? '$tablePrefix.' : null);
}
@override
$NcAlbumItemsTable createAlias(String alias) {
return $NcAlbumItemsTable(attachedDatabase, alias);
}
}
abstract class _$SqliteDb extends GeneratedDatabase {
_$SqliteDb(QueryExecutor e) : super(SqlTypeSystem.defaultInstance, e);
_$SqliteDb.connect(DatabaseConnection c) : super.connect(c);
@ -4172,6 +4805,8 @@ abstract class _$SqliteDb extends GeneratedDatabase {
late final $AlbumSharesTable albumShares = $AlbumSharesTable(this);
late final $TagsTable tags = $TagsTable(this);
late final $PersonsTable persons = $PersonsTable(this);
late final $NcAlbumsTable ncAlbums = $NcAlbumsTable(this);
late final $NcAlbumItemsTable ncAlbumItems = $NcAlbumItemsTable(this);
@override
Iterable<TableInfo> get allTables => allSchemaEntities.whereType<TableInfo>();
@override
@ -4187,7 +4822,9 @@ abstract class _$SqliteDb extends GeneratedDatabase {
albums,
albumShares,
tags,
persons
persons,
ncAlbums,
ncAlbumItems
];
}

View file

@ -0,0 +1,137 @@
part of '../database.dart';
extension SqliteDbNcAlbumExtension on SqliteDb {
Future<List<NcAlbum>> ncAlbumsByAccount({
required ByAccount account,
}) {
assert((account.sqlAccount != null) != (account.appAccount != null));
if (account.sqlAccount != null) {
final query = select(ncAlbums)
..where((t) => t.account.equals(account.sqlAccount!.rowId));
return query.get();
} else {
final query = select(ncAlbums).join([
innerJoin(accounts, accounts.rowId.equalsExp(ncAlbums.account),
useColumns: false),
innerJoin(servers, servers.rowId.equalsExp(accounts.server),
useColumns: false),
])
..where(servers.address.equals(account.appAccount!.url))
..where(accounts.userId
.equals(account.appAccount!.userId.toCaseInsensitiveString()));
return query.map((r) => r.readTable(ncAlbums)).get();
}
}
Future<List<List>> partialNcAlbumsByAccount({
required ByAccount account,
required List<Expression> columns,
}) {
final query = selectOnly(ncAlbums)..addColumns(columns);
if (account.sqlAccount != null) {
query.where(ncAlbums.account.equals(account.sqlAccount!.rowId));
} else {
query.join([
innerJoin(accounts, accounts.rowId.equalsExp(ncAlbums.account),
useColumns: false),
innerJoin(servers, servers.rowId.equalsExp(accounts.server),
useColumns: false),
])
..where(servers.address.equals(account.appAccount!.url))
..where(accounts.userId
.equals(account.appAccount!.userId.toCaseInsensitiveString()));
}
return query.map((r) => columns.map((c) => r.read(c)).toList()).get();
}
Future<NcAlbum?> ncAlbumByRelativePath({
required ByAccount account,
required String relativePath,
}) {
if (account.sqlAccount != null) {
final query = select(ncAlbums)
..where((t) => t.account.equals(account.sqlAccount!.rowId))
..where((t) => t.relativePath.equals(relativePath));
return query.getSingleOrNull();
} else {
final query = select(ncAlbums).join([
innerJoin(accounts, accounts.rowId.equalsExp(ncAlbums.account),
useColumns: false),
innerJoin(servers, servers.rowId.equalsExp(accounts.server),
useColumns: false),
])
..where(servers.address.equals(account.appAccount!.url))
..where(accounts.userId
.equals(account.appAccount!.userId.toCaseInsensitiveString()))
..where(ncAlbums.relativePath.equals(relativePath));
return query.map((r) => r.readTable(ncAlbums)).getSingleOrNull();
}
}
Future<void> insertNcAlbum({
required ByAccount account,
required NcAlbumsCompanion object,
}) async {
final Account dbAccount;
if (account.sqlAccount != null) {
dbAccount = account.sqlAccount!;
} else {
dbAccount = await accountOf(account.appAccount!);
}
await into(ncAlbums).insert(object.copyWith(
account: Value(dbAccount.rowId),
));
}
/// Delete [NaAlbum] by relativePath
///
/// Return the number of deleted rows
Future<int> deleteNcAlbumByRelativePath({
required ByAccount account,
required String relativePath,
}) async {
final Account dbAccount;
if (account.sqlAccount != null) {
dbAccount = account.sqlAccount!;
} else {
dbAccount = await accountOf(account.appAccount!);
}
return await (delete(ncAlbums)
..where((t) => t.account.equals(dbAccount.rowId))
..where((t) => t.relativePath.equals(relativePath)))
.go();
}
Future<List<NcAlbumItem>> ncAlbumItemsByParent({
required NcAlbum parent,
}) {
final query = select(ncAlbumItems)
..where((t) => t.parent.equals(parent.rowId));
return query.get();
}
Future<List<NcAlbumItem>> ncAlbumItemsByParentRelativePath({
required ByAccount account,
required String parentRelativePath,
}) {
final query = select(ncAlbumItems).join([
innerJoin(ncAlbums, ncAlbums.rowId.equalsExp(ncAlbumItems.parent),
useColumns: false),
]);
if (account.sqlAccount != null) {
query.where(ncAlbums.account.equals(account.sqlAccount!.rowId));
} else {
query.join([
innerJoin(accounts, accounts.rowId.equalsExp(ncAlbums.account),
useColumns: false),
innerJoin(servers, servers.rowId.equalsExp(accounts.server),
useColumns: false),
])
..where(servers.address.equals(account.appAccount!.url))
..where(accounts.userId
.equals(account.appAccount!.userId.toCaseInsensitiveString()));
}
query.where(ncAlbums.relativePath.equals(parentRelativePath));
return query.map((r) => r.readTable(ncAlbumItems)).get();
}
}

View file

@ -42,6 +42,32 @@ extension FileListExtension on List<app.File> {
}
}
class FileDescriptor {
const FileDescriptor({
required this.relativePath,
required this.fileId,
required this.contentType,
required this.isArchived,
required this.isFavorite,
required this.bestDateTime,
});
final String relativePath;
final int fileId;
final String? contentType;
final bool? isArchived;
final bool? isFavorite;
final DateTime bestDateTime;
}
extension FileDescriptorListExtension on List<FileDescriptor> {
List<app.FileDescriptor> convertToAppFileDescriptor(app.Account account) {
return map((f) =>
SqliteFileDescriptorConverter.fromSql(account.userId.toString(), f))
.toList();
}
}
class AlbumWithShare {
const AlbumWithShare(this.album, this.share);
@ -75,6 +101,20 @@ class AccountFileRowIdsWithFileId {
final int fileId;
}
class ByAccount {
const ByAccount.sql(Account account) : this._(sqlAccount: account);
const ByAccount.app(app.Account account) : this._(appAccount: account);
const ByAccount._({
this.sqlAccount,
this.appAccount,
}) : assert((sqlAccount != null) != (appAccount != null));
final Account? sqlAccount;
final app.Account? appAccount;
}
extension SqliteDbExtension on SqliteDb {
/// Start a transaction and run [block]
///
@ -234,7 +274,7 @@ extension SqliteDbExtension on SqliteDb {
///
/// Only one of [sqlAccount] and [appAccount] must be passed
Future<AccountFileRowIds?> accountFileRowIdsOfOrNull(
app.File file, {
app.FileDescriptor file, {
Account? sqlAccount,
app.Account? appAccount,
}) {
@ -250,9 +290,9 @@ extension SqliteDbExtension on SqliteDb {
} else {
q.setAppAccount(appAccount!);
}
if (file.fileId != null) {
q.byFileId(file.fileId!);
} else {
try {
q.byFileId(file.fdId);
} catch (_) {
q.byRelativePath(file.strippedPathWithEmpty);
}
return q.build()..limit(1);
@ -268,7 +308,7 @@ extension SqliteDbExtension on SqliteDb {
/// See [accountFileRowIdsOfOrNull]
Future<AccountFileRowIds> accountFileRowIdsOf(
app.File file, {
app.FileDescriptor file, {
Account? sqlAccount,
app.Account? appAccount,
}) =>
@ -398,6 +438,45 @@ extension SqliteDbExtension on SqliteDb {
.get();
}
/// Query [FileDescriptor]s by fileId
///
/// Returned files are NOT guaranteed to be sorted as [fileIds]
Future<List<FileDescriptor>> fileDescriptorsByFileIds(
ByAccount account, Iterable<int> fileIds) {
return fileIds.withPartition((sublist) {
final query = queryFiles().run((q) {
q.setQueryMode(
FilesQueryMode.expression,
expressions: [
accountFiles.relativePath,
files.fileId,
files.contentType,
accountFiles.isArchived,
accountFiles.isFavorite,
accountFiles.bestDateTime,
],
);
if (account.sqlAccount != null) {
q.setSqlAccount(account.sqlAccount!);
} else {
q.setAppAccount(account.appAccount!);
}
q.byFileIds(sublist);
return q.build();
});
return query
.map((r) => FileDescriptor(
relativePath: r.read(accountFiles.relativePath)!,
fileId: r.read(files.fileId)!,
contentType: r.read(files.contentType),
isArchived: r.read(accountFiles.isArchived),
isFavorite: r.read(accountFiles.isFavorite),
bestDateTime: r.read(accountFiles.bestDateTime)!,
))
.get();
}, maxByFileIdsSize);
}
Future<List<Tag>> allTags({
Account? sqlAccount,
app.Account? appAccount,
@ -553,6 +632,8 @@ extension SqliteDbExtension on SqliteDb {
await delete(albumShares).go();
await delete(tags).go();
await delete(persons).go();
await delete(ncAlbums).go();
await delete(ncAlbumItems).go();
// reset the auto increment counter
await customStatement("UPDATE sqlite_sequence SET seq=0;");

View file

@ -125,6 +125,37 @@ class DirFiles extends Table {
get primaryKey => {dir, child};
}
class NcAlbums extends Table {
IntColumn get rowId => integer().autoIncrement()();
IntColumn get account =>
integer().references(Accounts, #rowId, onDelete: KeyAction.cascade)();
TextColumn get relativePath => text()();
IntColumn get lastPhoto => integer().nullable()();
IntColumn get nbItems => integer()();
TextColumn get location => text().nullable()();
DateTimeColumn get dateStart =>
dateTime().map(const SqliteDateTimeConverter()).nullable()();
DateTimeColumn get dateEnd =>
dateTime().map(const SqliteDateTimeConverter()).nullable()();
@override
List<Set<Column>>? get uniqueKeys => [
{account, relativePath},
];
}
class NcAlbumItems extends Table {
IntColumn get rowId => integer().autoIncrement()();
IntColumn get parent =>
integer().references(NcAlbums, #rowId, onDelete: KeyAction.cascade)();
IntColumn get fileId => integer()();
@override
List<Set<Column>>? get uniqueKeys => [
{parent, fileId},
];
}
class Albums extends Table {
IntColumn get rowId => integer().autoIncrement()();
IntColumn get file => integer()

View file

@ -8,6 +8,7 @@ import 'package:nc_photos/entity/album/sort_provider.dart';
import 'package:nc_photos/entity/exif.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/nc_album.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/entity/sqlite/database.dart' as sql;
import 'package:nc_photos/entity/tag.dart';
@ -110,6 +111,19 @@ class SqliteAlbumConverter {
}
}
class SqliteFileDescriptorConverter {
static FileDescriptor fromSql(String userId, sql.FileDescriptor f) {
return FileDescriptor(
fdPath: "remote.php/dav/files/$userId/${f.relativePath}",
fdId: f.fileId,
fdMime: f.contentType,
fdIsArchived: f.isArchived ?? false,
fdIsFavorite: f.isFavorite ?? false,
fdDateTime: f.bestDateTime,
);
}
}
class SqliteFileConverter {
static File fromSql(String userId, sql.CompleteFile f) {
final metadata = f.image?.run((obj) => Metadata(
@ -239,6 +253,43 @@ class SqlitePersonConverter {
);
}
class SqliteNcAlbumConverter {
static NcAlbum fromSql(String userId, sql.NcAlbum ncAlbum) => NcAlbum(
path: "remote.php/dav/photos/$userId/albums/${ncAlbum.relativePath}",
lastPhoto: ncAlbum.lastPhoto,
nbItems: ncAlbum.nbItems,
location: ncAlbum.location,
dateStart: ncAlbum.dateStart,
dateEnd: ncAlbum.dateEnd,
collaborators: [],
);
static sql.NcAlbumsCompanion toSql(sql.Account? dbAccount, NcAlbum ncAlbum) =>
sql.NcAlbumsCompanion(
account:
dbAccount == null ? const Value.absent() : Value(dbAccount.rowId),
relativePath: Value(ncAlbum.strippedPath),
lastPhoto: Value(ncAlbum.lastPhoto),
nbItems: Value(ncAlbum.nbItems),
location: Value(ncAlbum.location),
dateStart: Value(ncAlbum.dateStart),
dateEnd: Value(ncAlbum.dateEnd),
);
}
class SqliteNcAlbumItemConverter {
static int fromSql(sql.NcAlbumItem item) => item.fileId;
static sql.NcAlbumItemsCompanion toSql(
sql.NcAlbum parent,
int fileId,
) =>
sql.NcAlbumItemsCompanion(
parent: Value(parent.rowId),
fileId: Value(fileId),
);
}
sql.TagsCompanion _convertAppTag(Map map) {
final account = map["account"] as sql.Account?;
final tag = map["tag"] as Tag;

View file

@ -6,6 +6,7 @@ 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/entity/local_file.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/pref.dart';
@ -78,7 +79,7 @@ class FileRemovedEvent {
FileRemovedEvent(this.account, this.file);
final Account account;
final File file;
final FileDescriptor file;
}
class FileTrashbinRestoredEvent {

View file

@ -1,7 +1,7 @@
import 'package:np_api/np_api.dart';
class CacheNotFoundException implements Exception {
CacheNotFoundException([this.message]);
const CacheNotFoundException([this.message]);
@override
toString() {
@ -94,3 +94,12 @@ class InterruptedException implements Exception {
final dynamic message;
}
class AlbumItemPermissionException implements Exception {
const AlbumItemPermissionException([this.message]);
@override
toString() => "AlbumItemPermissionException: $message";
final dynamic message;
}

View file

@ -20,6 +20,8 @@ class CustomizableMaterialPageRoute extends MaterialPageRoute {
String getImageHeroTag(FileDescriptor file) => "imageHero(${file.fdPath})";
String getCollectionHeroTag(String coverUrl) => "collectionHero($coverUrl)";
// copied from flutter
Widget defaultHeroFlightShuttleBuilder(
BuildContext flightContext,

16
app/lib/lazy.dart Normal file
View file

@ -0,0 +1,16 @@
class Lazy<T> {
Lazy(this.build);
T call() {
if (build != null) {
_value = build!();
build = null;
}
return _value;
}
T get get => call();
T Function()? build;
late final T _value;
}

View file

@ -49,4 +49,8 @@ extension ListExtension<T> on List<T> {
}
return this;
}
Future<List<U>> asyncMap<U>(Future<U> Function(T element) fn) {
return Stream.fromIterable(this).asyncMap(fn).toList();
}
}

View file

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/app_init.dart' as app_init;
import 'package:nc_photos/bloc_util.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/widget/my_app.dart';
import 'package:np_codegen/np_codegen.dart';
@ -33,8 +34,9 @@ void main() async {
@npLog
class _BlocObserver extends BlocObserver {
@override
onChange(BlocBase bloc, Change change) {
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
_log.finer("${bloc.runtimeType} $change");
final tag = bloc is BlocTag ? (bloc as BlocTag).tag : bloc.runtimeType;
_log.finer("$tag $change");
}
}

View file

@ -4,7 +4,6 @@ class M3 extends ThemeExtension<M3> {
const M3({
required this.seed,
required this.checkbox,
required this.assistChip,
required this.filterChip,
required this.listTile,
});
@ -15,14 +14,12 @@ class M3 extends ThemeExtension<M3> {
M3 copyWith({
Color? seed,
M3Checkbox? checkbox,
M3AssistChip? assistChip,
M3FilterChip? filterChip,
M3ListTile? listTile,
}) =>
M3(
seed: seed ?? this.seed,
checkbox: checkbox ?? this.checkbox,
assistChip: assistChip ?? this.assistChip,
filterChip: filterChip ?? this.filterChip,
listTile: listTile ?? this.listTile,
);
@ -35,7 +32,6 @@ class M3 extends ThemeExtension<M3> {
return M3(
seed: Color.lerp(seed, other.seed, t)!,
checkbox: checkbox.lerp(other.checkbox, t),
assistChip: assistChip.lerp(other.assistChip, t),
filterChip: filterChip.lerp(other.filterChip, t),
listTile: listTile.lerp(other.listTile, t),
);
@ -43,7 +39,6 @@ class M3 extends ThemeExtension<M3> {
final Color seed;
final M3Checkbox checkbox;
final M3AssistChip assistChip;
final M3FilterChip filterChip;
final M3ListTile listTile;
}
@ -82,44 +77,6 @@ class M3CheckboxDisabled {
final Color container;
}
class M3AssistChip {
const M3AssistChip({
required this.enabled,
});
M3AssistChip lerp(M3AssistChip? other, double t) {
if (other is! M3AssistChip) {
return this;
}
return M3AssistChip(
enabled: enabled.lerp(other.enabled, t),
);
}
final M3AssistChipEnabled enabled;
}
class M3AssistChipEnabled {
const M3AssistChipEnabled({
required this.container,
required this.containerElevated,
});
M3AssistChipEnabled lerp(M3AssistChipEnabled? other, double t) {
if (other is! M3AssistChipEnabled) {
return this;
}
return M3AssistChipEnabled(
container: Color.lerp(container, other.container, t)!,
containerElevated:
Color.lerp(containerElevated, other.containerElevated, t)!,
);
}
final Color container;
final Color containerElevated;
}
class M3FilterChip {
const M3FilterChip({
required this.disabled,

View file

@ -0,0 +1,7 @@
import 'package:rxdart/rxdart.dart';
extension BehaviorSubjectExtension<T> on BehaviorSubject<T> {
void addWithValue(T Function(T value) adder) {
add(adder(value));
}
}

View file

@ -202,12 +202,6 @@ ThemeData _applyColorScheme(ColorScheme colorScheme, Color seedColor) {
container: colorScheme.onSurface.withOpacity(.38),
),
),
assistChip: M3AssistChip(
enabled: M3AssistChipEnabled(
container: Colors.transparent,
containerElevated: colorScheme.surface,
),
),
filterChip: M3FilterChip(
disabled: M3FilterChipDisabled(
containerSelected: colorScheme.onSurface.withOpacity(.12),

View file

@ -1,3 +1,4 @@
import 'package:clock/clock.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/debug_util.dart';
@ -10,30 +11,31 @@ import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/override_comparator.dart';
import 'package:nc_photos/use_case/create_share.dart';
import 'package:nc_photos/use_case/inflate_file_descriptor.dart';
import 'package:nc_photos/use_case/list_share.dart';
import 'package:nc_photos/use_case/preprocess_album.dart';
import 'package:nc_photos/use_case/update_album.dart';
import 'package:nc_photos/use_case/update_album_with_actual_items.dart';
import 'package:np_codegen/np_codegen.dart';
part 'add_to_album.g.dart';
part 'add_file_to_album.g.dart';
@npLog
class AddToAlbum {
AddToAlbum(this._c)
: assert(require(_c)),
assert(ListShare.require(_c)),
assert(PreProcessAlbum.require(_c));
class AddFileToAlbum {
AddFileToAlbum(this._c) : assert(require(_c));
static bool require(DiContainer c) =>
DiContainer.has(c, DiType.albumRepo) &&
DiContainer.has(c, DiType.shareRepo);
DiContainer.has(c, DiType.shareRepo) &&
ListShare.require(c) &&
PreProcessAlbum.require(c);
/// Add a list of AlbumItems to [album]
/// Add list of files to [album]
Future<Album> call(
Account account, Album album, List<AlbumItem> items) async {
_log.info("[call] Add ${items.length} items to album '${album.name}'");
Account account, Album album, List<FileDescriptor> fds) async {
_log.info("[call] Add ${fds.length} items to album '${album.name}'");
assert(album.provider is AlbumStaticProvider);
final files = await InflateFileDescriptor(_c)(account, fds);
// resync is needed to work out album cover and latest item
final oldItems = await PreProcessAlbum(_c)(account, album);
final itemSet = oldItems
@ -41,7 +43,12 @@ class AddToAlbum {
e, _isItemFileEqual, _getItemHashCode))
.toSet();
// find the items that are not having the same file as any existing ones
final addItems = items
final addItems = files
.map((f) => AlbumFileItem(
addedBy: account.userId,
addedAt: clock.now(),
file: f,
))
.where((i) => itemSet.add(OverrideComparator<AlbumItem>(
i, _isItemFileEqual, _getItemHashCode)))
.toList();

View file

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

View file

@ -0,0 +1,54 @@
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/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_item/util.dart';
import 'package:nc_photos/use_case/update_album.dart';
import 'package:np_codegen/np_codegen.dart';
part 'edit_album.g.dart';
@npLog
class EditAlbum {
const EditAlbum(this._c);
/// Modify an [album]
Future<Album> call(
Account account,
Album album, {
String? name,
List<AlbumItem>? items,
CollectionItemSort? itemSort,
}) async {
_log.info(
"[call] Edit album ${album.name}, name: $name, items: $items, itemSort: $itemSort");
var newAlbum = album;
if (name != null) {
newAlbum = newAlbum.copyWith(name: name);
}
if (items != null) {
if (album.provider is AlbumStaticProvider) {
newAlbum = newAlbum.copyWith(
provider: (album.provider as AlbumStaticProvider).copyWith(
items: items,
),
);
}
}
if (itemSort != null) {
newAlbum = newAlbum.copyWith(
sortProvider: AlbumSortProvider.fromCollectionItemSort(itemSort),
);
}
if (identical(newAlbum, album)) {
return album;
}
await UpdateAlbum(_c.albumRepo)(account, newAlbum);
return newAlbum;
}
final DiContainer _c;
}

View file

@ -1,14 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'add_to_album.dart';
part of 'edit_album.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$AddToAlbumNpLog on AddToAlbum {
extension _$EditAlbumNpLog on EditAlbum {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("use_case.add_to_album.AddToAlbum");
static final log = Logger("use_case.album.edit_album.EditAlbum");
}

View file

@ -0,0 +1,78 @@
import 'package:collection/collection.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/file.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util;
import 'package:nc_photos/use_case/compat/v15.dart';
import 'package:nc_photos/use_case/compat/v25.dart';
import 'package:nc_photos/use_case/ls.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/type.dart';
part 'list_album2.g.dart';
@npLog
class ListAlbum2 {
ListAlbum2(this._c) : assert(require(_c));
static bool require(DiContainer c) =>
DiContainer.has(c, DiType.albumRepo) &&
DiContainer.has(c, DiType.fileRepo);
Stream<List<Album>> call(
Account account, {
ErrorHandler? onError,
}) async* {
var hasAlbum = false;
try {
await for (final result in _call(account, onError: onError)) {
hasAlbum = true;
yield result;
}
} catch (e) {
if (e is ApiException && e.response.statusCode == 404) {
// no albums
return;
} else {
rethrow;
}
}
if (!hasAlbum) {
if (await CompatV15.migrateAlbumFiles(account, _c.fileRepo)) {
// migrated, try again
yield* _call(account);
}
}
}
Stream<List<Album>> _call(
Account account, {
ErrorHandler? onError,
}) async* {
final ls = await Ls(_c.fileRepo)(
account,
File(
path: remote_storage_util.getRemoteAlbumsDir(account),
));
final List<File?> albumFiles =
ls.where((element) => element.isCollection != true).toList();
// migrate files
for (var i = 0; i < albumFiles.length; ++i) {
final f = albumFiles[i]!;
try {
if (CompatV25.isAlbumFileNeedMigration(f)) {
albumFiles[i] = await CompatV25.migrateAlbumFile(_c, account, f);
}
} catch (e, stackTrace) {
onError?.call(e, stackTrace);
albumFiles[i] = null;
}
}
yield* _c.albumRepo2.getAlbums(account, albumFiles.whereNotNull().toList());
}
final DiContainer _c;
}

View file

@ -1,14 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'home_albums.dart';
part of 'list_album2.dart';
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$_HomeAlbumsStateNpLog on _HomeAlbumsState {
extension _$ListAlbum2NpLog on ListAlbum2 {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("widget.home_albums._HomeAlbumsState");
static final log = Logger("use_case.album.list_album2.ListAlbum2");
}

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