diff --git a/lib/app_db.dart b/lib/app_db.dart index fe34811a..048dc3e4 100644 --- a/lib/app_db.dart +++ b/lib/app_db.dart @@ -140,6 +140,7 @@ class AppDbAlbumEntry { upgraderV1: AlbumUpgraderV1(), upgraderV2: AlbumUpgraderV2(), upgraderV3: AlbumUpgraderV3(), + upgraderV4: AlbumUpgraderV4(), )!, ); } diff --git a/lib/entity/album.dart b/lib/entity/album.dart index c2692098..52bb375d 100644 --- a/lib/entity/album.dart +++ b/lib/entity/album.dart @@ -44,6 +44,7 @@ class Album with EquatableMixin { required AlbumUpgraderV1? upgraderV1, required AlbumUpgraderV2? upgraderV2, required AlbumUpgraderV3? upgraderV3, + required AlbumUpgraderV4? upgraderV4, }) { final jsonVersion = json["version"]; JsonObj? result = json; @@ -68,6 +69,13 @@ class Album with EquatableMixin { return null; } } + if (jsonVersion < 5) { + result = upgraderV4?.call(result); + if (result == null) { + _log.info("[fromJson] Version $jsonVersion not compatible"); + return null; + } + } return Album( lastUpdated: result["lastUpdated"] == null ? null @@ -168,7 +176,7 @@ class Album with EquatableMixin { final File? albumFile; /// versioning of this class, use to upgrade old persisted album - static const version = 4; + static const version = 5; } class AlbumRepo { @@ -224,6 +232,7 @@ class AlbumRemoteDataSource implements AlbumDataSource { upgraderV1: AlbumUpgraderV1(), upgraderV2: AlbumUpgraderV2(), upgraderV3: AlbumUpgraderV3(), + upgraderV4: AlbumUpgraderV4(), )! .copyWith( lastUpdated: OrNull(null), diff --git a/lib/entity/album/item.dart b/lib/entity/album/item.dart index 1fb5e8e2..387dc827 100644 --- a/lib/entity/album/item.dart +++ b/lib/entity/album/item.dart @@ -4,6 +4,7 @@ import 'package:equatable/equatable.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/list_extension.dart'; +import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/type.dart'; List makeDistinctAlbumItems(List items) => @@ -95,6 +96,10 @@ class AlbumFileItem extends AlbumItem with EquatableMixin { }; } + AlbumFileItem minimize() => AlbumFileItem( + file: file.copyWith(metadata: OrNull(null)), + ); + @override get props => [ // file is handled separately, see [equals] diff --git a/lib/entity/album/provider.dart b/lib/entity/album/provider.dart index 38f36a30..0c1e1543 100644 --- a/lib/entity/album/provider.dart +++ b/lib/entity/album/provider.dart @@ -6,7 +6,6 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/item.dart'; 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/type.dart'; @@ -58,13 +57,54 @@ abstract class AlbumProvider with EquatableMixin { static final _log = Logger("entity.album.provider.AlbumProvider"); } -class AlbumStaticProvider extends AlbumProvider { +abstract class AlbumProviderBase extends AlbumProvider { + const AlbumProviderBase({ + this.latestItemTime, + }); + + @override + toString({bool isDeep = false}) { + return "$runtimeType {" + "latestItemTime: $latestItemTime, " + "}"; + } + + @override + toContentJson() { + return { + if (latestItemTime != null) + "latestItemTime": latestItemTime!.toUtc().toIso8601String(), + }; + } + + @override + AlbumProviderBase copyWith({ + DateTime? latestItemTime, + }); + + @override + get props => [ + latestItemTime, + ]; + + @override + final DateTime? latestItemTime; +} + +class AlbumStaticProvider extends AlbumProviderBase { AlbumStaticProvider({ + DateTime? latestItemTime, required List items, - }) : items = UnmodifiableListView(items); + }) : items = UnmodifiableListView(items), + super( + latestItemTime: latestItemTime, + ); factory AlbumStaticProvider.fromJson(JsonObj json) { return AlbumStaticProvider( + latestItemTime: json["latestItemTime"] == null + ? null + : DateTime.parse(json["latestItemTime"]), items: (json["items"] as List) .map((e) => AlbumItem.fromJson(e.cast())) .toList(), @@ -79,6 +119,7 @@ class AlbumStaticProvider extends AlbumProvider { final itemsStr = isDeep ? items.toReadableString() : "List {length: ${items.length}}"; return "$runtimeType {" + "super: ${super.toString(isDeep: isDeep)}, " "items: $itemsStr, " "}"; } @@ -86,36 +127,25 @@ class AlbumStaticProvider extends AlbumProvider { @override toContentJson() { return { + ...super.toContentJson(), "items": items.map((e) => e.toJson()).toList(), }; } @override AlbumStaticProvider copyWith({ + DateTime? latestItemTime, List? items, }) { return AlbumStaticProvider( + latestItemTime: latestItemTime ?? this.latestItemTime, items: items ?? this.items, ); } - @override - get latestItemTime { - try { - return items - .whereType() - .map((e) => e.file) - .where((element) => file_util.isSupportedFormat(element)) - .sorted(compareFileDateTimeDescending) - .first - .bestDateTime; - } catch (_) { - return null; - } - } - @override get props => [ + ...super.props, items, ]; @@ -125,55 +155,28 @@ class AlbumStaticProvider extends AlbumProvider { static const _type = "static"; } -abstract class AlbumDynamicProvider extends AlbumProvider { - AlbumDynamicProvider({ +abstract class AlbumDynamicProvider extends AlbumProviderBase { + const AlbumDynamicProvider({ DateTime? latestItemTime, - }) : _latestItemTime = latestItemTime; - - @override - toString({bool isDeep = false}) { - return "$runtimeType {" - "latestItemTime: $_latestItemTime, " - "}"; - } - - @override - toContentJson() { - return { - "latestItemTime": _latestItemTime?.toUtc().toIso8601String(), - }; - } - - @override - AlbumDynamicProvider copyWith({ - DateTime? latestItemTime, - }); - - @override - get latestItemTime => _latestItemTime; - - @override - get props => [ - _latestItemTime, - ]; - - final DateTime? _latestItemTime; + }) : super(latestItemTime: latestItemTime); } class AlbumDirProvider extends AlbumDynamicProvider { - AlbumDirProvider({ + const AlbumDirProvider({ required this.dirs, DateTime? latestItemTime, - }) : super(latestItemTime: latestItemTime); + }) : super( + latestItemTime: latestItemTime, + ); factory AlbumDirProvider.fromJson(JsonObj json) { return AlbumDirProvider( - dirs: (json["dirs"] as List) - .map((e) => File.fromJson(e.cast())) - .toList(), latestItemTime: json["latestItemTime"] == null ? null : DateTime.parse(json["latestItemTime"]), + dirs: (json["dirs"] as List) + .map((e) => File.fromJson(e.cast())) + .toList(), ); } @@ -195,12 +198,12 @@ class AlbumDirProvider extends AlbumDynamicProvider { @override AlbumDirProvider copyWith({ - List? dirs, DateTime? latestItemTime, + List? dirs, }) { return AlbumDirProvider( - dirs: dirs ?? this.dirs, latestItemTime: latestItemTime ?? this.latestItemTime, + dirs: dirs ?? this.dirs, ); } diff --git a/lib/entity/album/upgrader.dart b/lib/entity/album/upgrader.dart index 76d46359..e64598ac 100644 --- a/lib/entity/album/upgrader.dart +++ b/lib/entity/album/upgrader.dart @@ -1,5 +1,8 @@ import 'package:logging/logging.dart'; +import 'package:nc_photos/entity/exif.dart'; +import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/type.dart'; +import 'package:tuple/tuple.dart'; abstract class AlbumUpgrader { JsonObj? call(JsonObj json); @@ -85,3 +88,72 @@ class AlbumUpgraderV3 implements AlbumUpgrader { static final _log = Logger("entity.album.upgrader.AlbumUpgraderV3"); } + +/// Upgrade v4 Album to v5 +class AlbumUpgraderV4 implements AlbumUpgrader { + AlbumUpgraderV4({ + this.logFilePath, + }); + + @override + call(JsonObj json) { + _log.fine("[call] Upgrade v4 Album for file: $logFilePath"); + final result = JsonObj.from(json); + try { + if (result["provider"]["type"] != "static") { + return result; + } + final latestItem = (result["provider"]["content"]["items"] as List) + .map((e) => e.cast()) + .where((e) => e["type"] == "file") + .map((e) => e["content"]["file"] as JsonObj) + .map((e) { + final overrideDateTime = e["overrideDateTime"] == null + ? null + : DateTime.parse(e["overrideDateTime"]); + final String? dateTimeOriginalStr = + e["metadata"]?["exif"]?["DateTimeOriginal"]; + final dateTimeOriginal = + dateTimeOriginalStr == null || dateTimeOriginalStr.isEmpty + ? null + : Exif.dateTimeFormat.parse(dateTimeOriginalStr).toUtc(); + final lastModified = e["lastModified"] == null + ? null + : DateTime.parse(e["lastModified"]); + final latestItemTime = + overrideDateTime ?? dateTimeOriginal ?? lastModified; + + // remove metadata + e.remove("metadata"); + if (latestItemTime != null) { + return Tuple2(latestItemTime, e); + } else { + return null; + } + }) + .whereType>() + .sorted((a, b) => a.item1.compareTo(b.item1)) + .lastOrNull; + if (latestItem != null) { + // save the latest item time + result["provider"]["content"]["latestItemTime"] = + latestItem.item1.toIso8601String(); + if (result["coverProvider"]["type"] == "auto") { + // save the cover + result["coverProvider"]["content"]["coverFile"] = + Map.of(latestItem.item2); + } + } + } catch (e, stackTrace) { + // this upgrade is not a must, if it failed then just leave it and it'll + // be upgraded the next time the album is saved + _log.shout("[call] Failed while upgrade", e, stackTrace); + } + return result; + } + + /// File path for logging only + final String? logFilePath; + + static final _log = Logger("entity.album.upgrader.AlbumUpgraderV4"); +} diff --git a/lib/iterable_extension.dart b/lib/iterable_extension.dart index a91d973e..cff285ed 100644 --- a/lib/iterable_extension.dart +++ b/lib/iterable_extension.dart @@ -49,4 +49,12 @@ extension IterableExtension on Iterable { return null; } } + + T? get lastOrNull { + try { + return last; + } on StateError catch (_) { + return null; + } + } } diff --git a/lib/use_case/add_to_album.dart b/lib/use_case/add_to_album.dart index 5a26c853..edc0d23e 100644 --- a/lib/use_case/add_to_album.dart +++ b/lib/use_case/add_to_album.dart @@ -1,24 +1,43 @@ +import 'package:logging/logging.dart'; import 'package:nc_photos/account.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/use_case/resync_album.dart'; import 'package:nc_photos/use_case/update_album.dart'; +import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; class AddToAlbum { AddToAlbum(this.albumRepo); /// Add a list of AlbumItems to [album] - Future call(Account account, Album album, List items) => - UpdateAlbum(albumRepo)( - account, - album.copyWith( - provider: AlbumStaticProvider.of(album).copyWith( - items: makeDistinctAlbumItems([ - ...items, - ...AlbumStaticProvider.of(album).items, - ]), - ), - )); + Future call( + Account account, Album album, List items) async { + _log.info("[call] Add ${items.length} items to album '${album.name}'"); + assert(album.provider is AlbumStaticProvider); + // resync is needed to work out album cover and latest item + final oldItems = await ResyncAlbum()(account, album); + final newItems = makeDistinctAlbumItems([ + ...items, + ...oldItems, + ]); + var newAlbum = album.copyWith( + provider: AlbumStaticProvider.of(album).copyWith( + items: newItems, + ), + ); + // UpdateAlbumWithActualItems only persists when there are changes to + // several properties, so we can't rely on it + newAlbum = await UpdateAlbumWithActualItems(null)( + account, + newAlbum, + newItems, + ); + await UpdateAlbum(albumRepo)(account, newAlbum); + return newAlbum; + } final AlbumRepo albumRepo; + + static final _log = Logger("use_case.add_to_album.AddToAlbum"); } diff --git a/lib/use_case/remove_from_album.dart b/lib/use_case/remove_from_album.dart index b36108de..d51e89dc 100644 --- a/lib/use_case/remove_from_album.dart +++ b/lib/use_case/remove_from_album.dart @@ -3,8 +3,11 @@ import 'package:nc_photos/account.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/file.dart'; import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/use_case/resync_album.dart'; import 'package:nc_photos/use_case/update_album.dart'; +import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; class RemoveFromAlbum { RemoveFromAlbum(this.albumRepo); @@ -26,6 +29,18 @@ class RemoveFromAlbum { items: newItems, ), ); + // check if any of the removed items was the latest item + if (items.whereType().any((element) => + element.file.bestDateTime == album.provider.latestItemTime)) { + _log.info("[call] Resync as latest item is being removed"); + // need to update the album properties + final newItemsSynced = await ResyncAlbum()(account, newAlbum); + newAlbum = await UpdateAlbumWithActualItems(null)( + account, + newAlbum, + newItemsSynced, + ); + } await UpdateAlbum(albumRepo)(account, newAlbum); return newAlbum; } diff --git a/lib/use_case/resync_album.dart b/lib/use_case/resync_album.dart index 8269c3d0..b6d88dea 100644 --- a/lib/use_case/resync_album.dart +++ b/lib/use_case/resync_album.dart @@ -10,11 +10,11 @@ import 'package:nc_photos/entity/file_util.dart' as file_util; /// Resync files inside an album with the file db class ResyncAlbum { - Future call(Account account, Album album) async { + Future> call(Account account, Album album) async { + _log.info("[call] Resync album: ${album.name}"); if (album.provider is! AlbumStaticProvider) { - _log.warning( - "[call] Resync only make sense for static albums: ${album.name}"); - return album; + throw ArgumentError( + "Resync only make sense for static albums: ${album.name}"); } return await AppDb.use((db) async { final transaction = @@ -38,7 +38,7 @@ class ResyncAlbum { newItems.add(item); } } - return album.copyWith(provider: AlbumStaticProvider(items: newItems)); + return newItems; }); } diff --git a/lib/use_case/update_album.dart b/lib/use_case/update_album.dart index 0d1031bf..687513ea 100644 --- a/lib/use_case/update_album.dart +++ b/lib/use_case/update_album.dart @@ -2,15 +2,33 @@ import 'package:event_bus/event_bus.dart'; import 'package:kiwi/kiwi.dart'; import 'package:nc_photos/account.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/event/event.dart'; class UpdateAlbum { UpdateAlbum(this.albumRepo); Future call(Account account, Album album) async { - await albumRepo.update(account, album); + final provider = album.provider; + if (provider is AlbumStaticProvider) { + await albumRepo.update( + account, + album.copyWith( + provider: provider.copyWith( + items: _minimizeItems(provider.items), + ), + ), + ); + } else { + await albumRepo.update(account, album); + } KiwiContainer().resolve().fire(AlbumUpdatedEvent(account, album)); } + List _minimizeItems(List items) { + return items.map((e) => e is AlbumFileItem ? e.minimize() : e).toList(); + } + final AlbumRepo albumRepo; } diff --git a/lib/use_case/update_album_time.dart b/lib/use_case/update_album_time.dart new file mode 100644 index 00000000..0cc2120b --- /dev/null +++ b/lib/use_case/update_album_time.dart @@ -0,0 +1,56 @@ +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/file.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; + +class UpdateAlbumTime { + /// Update the latest item time of an album with unsorted items + /// + /// If no updates are needed, return the same object + Album call(Album album, List items) { + if (album.provider is! AlbumProviderBase) { + return album; + } else { + final sortedItems = + const AlbumTimeSortProvider(isAscending: false).sort(items); + return _updateWithSortedItems(album, sortedItems); + } + } + + /// Update the latest item time of an album with pre-sorted files + /// + /// The album items are expected to be sorted by [AlbumTimeSortProvider] with + /// isAscending = false, otherwise please call the unsorted version. If no + /// updates are needed, return the same object + Album updateWithSortedItems(Album album, List sortedItems) { + if (album.provider is! AlbumProviderBase) { + return album; + } else { + return _updateWithSortedItems(album, sortedItems); + } + } + + Album _updateWithSortedItems(Album album, List sortedItems) { + DateTime? latestItemTime; + try { + final latestFile = sortedItems + .whereType() + .map((e) => e.file) + .where((element) => file_util.isSupportedFormat(element)) + .first; + latestItemTime = latestFile.bestDateTime; + } catch (_) { + latestItemTime = null; + } + if (latestItemTime != album.provider.latestItemTime) { + return album.copyWith( + provider: (album.provider as AlbumProviderBase).copyWith( + latestItemTime: latestItemTime, + ), + ); + } + return album; + } +} diff --git a/lib/use_case/update_album_with_actual_items.dart b/lib/use_case/update_album_with_actual_items.dart new file mode 100644 index 00000000..d2d8e708 --- /dev/null +++ b/lib/use_case/update_album_with_actual_items.dart @@ -0,0 +1,50 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/item.dart'; +import 'package:nc_photos/entity/album/sort_provider.dart'; +import 'package:nc_photos/use_case/update_album.dart'; +import 'package:nc_photos/use_case/update_album_time.dart'; +import 'package:nc_photos/use_case/update_auto_album_cover.dart'; + +class UpdateAlbumWithActualItems { + UpdateAlbumWithActualItems(this.albumRepo); + + /// Update, if necessary, [album] after resynced/populated with actual items + /// + /// If [albumRepo] is null, the modified album will not be saved + Future call( + Account account, Album album, List items) async { + final sortedItems = + const AlbumTimeSortProvider(isAscending: false).sort(items); + + bool shouldUpdate = false; + final albumUpdatedCover = + UpdateAutoAlbumCover().updateWithSortedItems(album, sortedItems); + if (!identical(albumUpdatedCover, album)) { + _log.info("[call] Update album cover"); + shouldUpdate = true; + } + album = albumUpdatedCover; + + final albumUpdatedTime = + UpdateAlbumTime().updateWithSortedItems(album, sortedItems); + if (!identical(albumUpdatedTime, album)) { + _log.info( + "[call] Update album time: ${album.provider.latestItemTime} -> ${albumUpdatedTime.provider.latestItemTime}"); + shouldUpdate = true; + } + album = albumUpdatedTime; + + if (albumRepo != null && shouldUpdate) { + _log.info("[call] Persist album"); + await UpdateAlbum(albumRepo!)(account, album); + } + return album; + } + + final AlbumRepo? albumRepo; + + static final _log = Logger( + "use_case.update_album_with_actual_items.UpdateAlbumWithActualItems"); +} diff --git a/lib/use_case/update_auto_album_cover.dart b/lib/use_case/update_auto_album_cover.dart new file mode 100644 index 00000000..7a343026 --- /dev/null +++ b/lib/use_case/update_auto_album_cover.dart @@ -0,0 +1,55 @@ +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/cover_provider.dart'; +import 'package:nc_photos/entity/album/item.dart'; +import 'package:nc_photos/entity/album/sort_provider.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; + +class UpdateAutoAlbumCover { + /// Update the AlbumAutoCoverProvider of an album with unsorted items + /// + /// If no updates are needed, return the same object + Album call(Album album, List items) { + if (album.coverProvider is! AlbumAutoCoverProvider) { + return album; + } else { + final sortedItems = + const AlbumTimeSortProvider(isAscending: false).sort(items); + return _updateWithSortedItems(album, sortedItems); + } + } + + /// Update the AlbumAutoCoverProvider of an album with pre-sorted files + /// + /// The album items are expected to be sorted by [AlbumTimeSortProvider] with + /// isAscending = false, otherwise please call the unsorted version. If no + /// updates are needed, return the same object + Album updateWithSortedItems(Album album, List sortedItems) { + if (album.coverProvider is! AlbumAutoCoverProvider) { + return album; + } else { + return _updateWithSortedItems(album, sortedItems); + } + } + + Album _updateWithSortedItems(Album album, List sortedItems) { + try { + final coverFile = sortedItems + .whereType() + .map((e) => e.file) + .where((element) => file_util.isSupportedFormat(element)) + .firstWhere((element) => element.hasPreview ?? false); + // cache the result for later use + if (coverFile.path != + (album.coverProvider as AlbumAutoCoverProvider).coverFile?.path) { + return album.copyWith( + coverProvider: AlbumAutoCoverProvider( + coverFile: coverFile, + ), + ); + } + } on StateError catch (_) { + // no files + } + return album; + } +} diff --git a/lib/use_case/update_dynamic_album_cover.dart b/lib/use_case/update_dynamic_album_cover.dart deleted file mode 100644 index 07a51cec..00000000 --- a/lib/use_case/update_dynamic_album_cover.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:nc_photos/entity/album.dart'; -import 'package:nc_photos/entity/album/cover_provider.dart'; -import 'package:nc_photos/entity/album/item.dart'; -import 'package:nc_photos/entity/album/provider.dart'; -import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/iterable_extension.dart'; - -class UpdateDynamicAlbumCover { - /// Update the cover of a dynamic album with unsorted items - /// - /// If no updates are needed, return the same object - Album call(Album album, List populatedItems) { - if (album.provider is! AlbumDynamicProvider || - album.coverProvider is! AlbumAutoCoverProvider) { - return album; - } else { - return _updateWithSortedFiles( - album, - populatedItems - .whereType() - .map((e) => e.file) - .where((element) => file_util.isSupportedFormat(element)) - .sorted(compareFileDateTimeDescending)); - } - } - - /// Update the cover of a dynamic album with pre-sorted files - /// - /// The album items are expected to be sorted by - /// [compareFileDateTimeDescending], otherwise please call the unsorted - /// version. If no updates are needed, return the same object - Album updateWithSortedFiles(Album album, List sortedFiles) { - if (album.provider is! AlbumDynamicProvider || - album.coverProvider is! AlbumAutoCoverProvider) { - return album; - } else { - return _updateWithSortedFiles(album, sortedFiles); - } - } - - Album _updateWithSortedFiles(Album album, List sortedFiles) { - try { - final coverFile = - sortedFiles.firstWhere((element) => element.hasPreview ?? false); - // cache the result for later use - if (coverFile.path != - (album.coverProvider as AlbumAutoCoverProvider).coverFile?.path) { - return album.copyWith( - coverProvider: AlbumAutoCoverProvider( - coverFile: coverFile, - ), - ); - } - } on StateError catch (_) { - // no files - } - return album; - } -} diff --git a/lib/use_case/update_dynamic_album_time.dart b/lib/use_case/update_dynamic_album_time.dart deleted file mode 100644 index da7c1ca2..00000000 --- a/lib/use_case/update_dynamic_album_time.dart +++ /dev/null @@ -1,55 +0,0 @@ -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/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; -import 'package:nc_photos/iterable_extension.dart'; - -class UpdateDynamicAlbumTime { - /// Update the latest item time of a dynamic album with unsorted items - /// - /// If no updates are needed, return the same object - Album call(Album album, List populatedItems) { - if (album.provider is! AlbumDynamicProvider) { - return album; - } else { - return _updateWithSortedFiles( - album, - populatedItems - .whereType() - .map((e) => e.file) - .where((element) => file_util.isSupportedFormat(element)) - .sorted(compareFileDateTimeDescending)); - } - } - - /// Update the latest item time of a dynamic album with pre-sorted files - /// - /// The album items are expected to be sorted by - /// [compareFileDateTimeDescending], otherwise please call the unsorted - /// version. If no updates are needed, return the same object - Album updateWithSortedFiles(Album album, List sortedFiles) { - if (album.provider is! AlbumDynamicProvider) { - return album; - } else { - return _updateWithSortedFiles(album, sortedFiles); - } - } - - Album _updateWithSortedFiles(Album album, List sortedFiles) { - DateTime? latestItemTime; - try { - latestItemTime = sortedFiles.first.bestDateTime; - } catch (_) { - latestItemTime = null; - } - if (latestItemTime != album.provider.latestItemTime) { - return album.copyWith( - provider: (album.provider as AlbumDynamicProvider).copyWith( - latestItemTime: latestItemTime, - ), - ); - } - return album; - } -} diff --git a/lib/widget/album_browser.dart b/lib/widget/album_browser.dart index 2b12cec9..d66f9c2d 100644 --- a/lib/widget/album_browser.dart +++ b/lib/widget/album_browser.dart @@ -14,8 +14,8 @@ 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/exception_util.dart' as exception_util; -import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/list_extension.dart'; import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/session_storage.dart'; @@ -25,6 +25,7 @@ import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/remove_from_album.dart'; import 'package:nc_photos/use_case/resync_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:nc_photos/widget/album_browser_mixin.dart'; import 'package:nc_photos/widget/draggable_item_list_mixin.dart'; import 'package:nc_photos/widget/fancy_option_picker.dart'; @@ -32,7 +33,6 @@ import 'package:nc_photos/widget/photo_list_item.dart'; import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; import 'package:nc_photos/widget/simple_input_dialog.dart'; import 'package:nc_photos/widget/viewer.dart'; -import 'package:quiver/iterables.dart'; class AlbumBrowserArguments { AlbumBrowserArguments(this.account, this.album); @@ -169,20 +169,10 @@ class _AlbumBrowserState extends State } } - void _initAlbum() { - assert(widget.album.provider is AlbumStaticProvider); - ResyncAlbum()(widget.account, widget.album).then((album) { - if (_shouldPropagateResyncedAlbum(album)) { - _propagateResyncedAlbum(album); - } - if (mounted) { - setState(() { - _album = album; - _transformItems(); - initCover(widget.account, album); - }); - } - }); + Future _initAlbum() async { + final albumRepo = AlbumRepo(AlbumCachedDataSource()); + final album = await albumRepo.get(widget.account, widget.album.albumFile!); + await _setAlbum(album); } Widget _buildContent(BuildContext context) { @@ -495,13 +485,9 @@ class _AlbumBrowserState extends State }); } - void _onAlbumUpdatedEvent(AlbumUpdatedEvent ev) { + Future _onAlbumUpdatedEvent(AlbumUpdatedEvent ev) async { if (ev.album.albumFile!.path == _album?.albumFile?.path) { - setState(() { - _album = ev.album; - _transformItems(); - initCover(widget.account, ev.album); - }); + await _setAlbum(ev.album); } } @@ -600,64 +586,29 @@ class _AlbumBrowserState extends State draggableItemList = items; } - void _propagateResyncedAlbum(Album album) { - final propagateItems = - zip([_getAlbumItemsOf(album), _getAlbumItemsOf(widget.album)]).map((e) { - if (e[0] is AlbumFileItem) { - final item = e[0] as AlbumFileItem; - if (!item.file.isOwned(widget.account.username)) { - // don't propagate shared file not owned by this user, this is to - // prevent multiple user having different properties to keep - // overriding each others - _log.info( - "[_propagateResyncedAlbum] Skip shared file: ${item.file.path}"); - return e[1]; - } - } - return e[0]; - }).toList(); - final propagateAlbum = album.copyWith( + Future _setAlbum(Album album) async { + assert(album.provider is AlbumStaticProvider); + final items = await ResyncAlbum()(widget.account, album); + album = album.copyWith( provider: AlbumStaticProvider.of(album).copyWith( - items: propagateItems, + items: items, ), ); - UpdateAlbum(AlbumRepo(AlbumCachedDataSource()))( - widget.account, propagateAlbum) - .catchError((e, stacktrace) { - _log.shout("[_propagateResyncedAlbum] Failed while updating album", e, - stacktrace); - }); + album = await _updateAlbumPostResync(album, items); + if (mounted) { + setState(() { + _album = album; + _transformItems(); + initCover(widget.account, album); + }); + } } - bool _shouldPropagateResyncedAlbum(Album album) { - final origItems = _getAlbumItemsOf(widget.album); - final resyncItems = _getAlbumItemsOf(album); - if (origItems.length != resyncItems.length) { - _log.info( - "[_shouldPropagateResyncedAlbum] Item length differ: ${origItems.length}, ${resyncItems.length}"); - return true; - } - for (final z in zip([origItems, resyncItems])) { - final a = z[0], b = z[1]; - bool isEqual; - if (a is AlbumFileItem && b is AlbumFileItem) { - if (!a.file.isOwned(widget.account.username)) { - // ignore shared files - continue; - } - // faster compare - isEqual = a.equals(b, isDeep: false); - } else { - isEqual = a == b; - } - if (!isEqual) { - _log.info( - "[_shouldPropagateResyncedAlbum] Item differ:\nOriginal: ${z[0]}\nResynced: ${z[1]}"); - return true; - } - } - _log.info("[_shouldPropagateResyncedAlbum] false"); - return false; + Future _updateAlbumPostResync( + Album album, List items) async { + final albumRepo = AlbumRepo(AlbumCachedDataSource()); + return await UpdateAlbumWithActualItems(albumRepo)( + widget.account, album, items); } static List _getAlbumItemsOf(Album a) => diff --git a/lib/widget/album_importer.dart b/lib/widget/album_importer.dart index 622759c6..f90ccdd4 100644 --- a/lib/widget/album_importer.dart +++ b/lib/widget/album_importer.dart @@ -8,11 +8,9 @@ import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/bloc/list_importable_album.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/cover_provider.dart'; -import 'package:nc_photos/entity/album/item.dart'; import 'package:nc_photos/entity/album/provider.dart'; import 'package:nc_photos/entity/album/sort_provider.dart'; import 'package:nc_photos/entity/file.dart'; -import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; @@ -20,8 +18,7 @@ import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/create_album.dart'; import 'package:nc_photos/use_case/populate_album.dart'; -import 'package:nc_photos/use_case/update_dynamic_album_cover.dart'; -import 'package:nc_photos/use_case/update_dynamic_album_time.dart'; +import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; import 'package:nc_photos/widget/processing_dialog.dart'; import 'package:path/path.dart' as path; @@ -239,15 +236,8 @@ class _AlbumImporterState extends State { _log.info("[_createAllAlbums] Creating dir album: $album"); final items = await PopulateAlbum()(widget.account, album); - final sortedFiles = items - .whereType() - .map((e) => e.file) - .where((element) => file_util.isSupportedFormat(element)) - .sorted(compareFileDateTimeDescending); - album = - UpdateDynamicAlbumCover().updateWithSortedFiles(album, sortedFiles); - album = - UpdateDynamicAlbumTime().updateWithSortedFiles(album, sortedFiles); + album = await UpdateAlbumWithActualItems(null)( + widget.account, album, items); final albumRepo = AlbumRepo(AlbumCachedDataSource()); await CreateAlbum(albumRepo)(widget.account, album); diff --git a/lib/widget/dynamic_album_browser.dart b/lib/widget/dynamic_album_browser.dart index fd4eef41..fe5a8b16 100644 --- a/lib/widget/dynamic_album_browser.dart +++ b/lib/widget/dynamic_album_browser.dart @@ -27,8 +27,7 @@ import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/populate_album.dart'; import 'package:nc_photos/use_case/remove.dart'; import 'package:nc_photos/use_case/update_album.dart'; -import 'package:nc_photos/use_case/update_dynamic_album_cover.dart'; -import 'package:nc_photos/use_case/update_dynamic_album_time.dart'; +import 'package:nc_photos/use_case/update_album_with_actual_items.dart'; import 'package:nc_photos/widget/album_browser_mixin.dart'; import 'package:nc_photos/widget/fancy_option_picker.dart'; import 'package:nc_photos/widget/photo_list_item.dart'; @@ -159,58 +158,16 @@ class _DynamicAlbumBrowserState extends State } } - void _initAlbum() { + Future _initAlbum() async { assert(widget.album.provider is AlbumDynamicProvider); - PopulateAlbum()(widget.account, widget.album).then((items) { - if (mounted) { - setState(() { - _album = widget.album; - _transformItems(items); - initCover(widget.account, widget.album); - _updateAlbumPostPopulate(items); - }); - } - }); - } - - Future _updateAlbumPostPopulate(List items) async { - final List timeDescSortedFiles; - if (widget.album.sortProvider is AlbumTimeSortProvider) { - if ((widget.album.sortProvider as AlbumTimeSortProvider).isAscending) { - timeDescSortedFiles = _backingFiles.reversed.toList(); - } else { - timeDescSortedFiles = _backingFiles; - } - } else { - timeDescSortedFiles = const AlbumTimeSortProvider(isAscending: false) - .sort(items) - .whereType() - .map((e) => e.file) - .where((element) => file_util.isSupportedFormat(element)) - .toList(); - } - - bool shouldUpdate = false; - final albumUpdatedCover = UpdateDynamicAlbumCover() - .updateWithSortedFiles(_album!, timeDescSortedFiles); - if (!identical(albumUpdatedCover, _album)) { - _log.info("[_updateAlbumPostPopulate] Update album cover"); - shouldUpdate = true; - } - _album = albumUpdatedCover; - - final albumUpdatedTime = UpdateDynamicAlbumTime() - .updateWithSortedFiles(_album!, timeDescSortedFiles); - if (!identical(albumUpdatedTime, _album)) { - _log.info( - "[_updateAlbumPostPopulate] Update album time: ${albumUpdatedTime.provider.latestItemTime}"); - shouldUpdate = true; - } - _album = albumUpdatedTime; - - if (shouldUpdate) { - await UpdateAlbum(AlbumRepo(AlbumCachedDataSource()))( - widget.account, _album!); + final items = await PopulateAlbum()(widget.account, widget.album); + final album = await _updateAlbumPostPopulate(widget.album, items); + if (mounted) { + setState(() { + _album = album; + _transformItems(items); + initCover(widget.account, widget.album); + }); } } @@ -376,6 +333,7 @@ class _DynamicAlbumBrowserState extends State widget.account, _album!.copyWith( provider: AlbumStaticProvider( + latestItemTime: _album!.provider.latestItemTime, items: _sortedItems, ), coverProvider: AlbumAutoCoverProvider(), @@ -513,12 +471,12 @@ class _DynamicAlbumBrowserState extends State }); } - void _onAlbumUpdatedEvent(AlbumUpdatedEvent ev) { + Future _onAlbumUpdatedEvent(AlbumUpdatedEvent ev) async { if (ev.album.albumFile!.path == _album?.albumFile?.path) { + final album = await _updateAlbumPostPopulate(ev.album, _sortedItems); setState(() { - _album = ev.album; - initCover(widget.account, ev.album); - _updateAlbumPostPopulate(_sortedItems); + _album = album; + initCover(widget.account, album); }); } } @@ -572,6 +530,13 @@ class _DynamicAlbumBrowserState extends State .toList(); } + Future _updateAlbumPostPopulate( + Album album, List items) async { + final albumRepo = AlbumRepo(AlbumCachedDataSource()); + return await UpdateAlbumWithActualItems(albumRepo)( + widget.account, album, items); + } + Album? _album; var _sortedItems = []; var _backingFiles = []; diff --git a/test/entity/album_test.dart b/test/entity/album_test.dart index bd3f97ea..b2f0ab62 100644 --- a/test/entity/album_test.dart +++ b/test/entity/album_test.dart @@ -1,3 +1,4 @@ +import 'package:intl/intl.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album/cover_provider.dart'; import 'package:nc_photos/entity/album/item.dart'; @@ -31,8 +32,13 @@ void main() { }, }; expect( - Album.fromJson(json, - upgraderV1: null, upgraderV2: null, upgraderV3: null), + Album.fromJson( + json, + upgraderV1: null, + upgraderV2: null, + upgraderV3: null, + upgraderV4: null, + ), Album( lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), name: "", @@ -65,8 +71,13 @@ void main() { }, }; expect( - Album.fromJson(json, - upgraderV1: null, upgraderV2: null, upgraderV3: null), + Album.fromJson( + json, + upgraderV1: null, + upgraderV2: null, + upgraderV3: null, + upgraderV4: null, + ), Album( lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), name: "album", @@ -117,8 +128,13 @@ void main() { }, }; expect( - Album.fromJson(json, - upgraderV1: null, upgraderV2: null, upgraderV3: null), + Album.fromJson( + json, + upgraderV1: null, + upgraderV2: null, + upgraderV3: null, + upgraderV4: null, + ), Album( lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), name: "", @@ -165,8 +181,13 @@ void main() { }, }; expect( - Album.fromJson(json, - upgraderV1: null, upgraderV2: null, upgraderV3: null), + Album.fromJson( + json, + upgraderV1: null, + upgraderV2: null, + upgraderV3: null, + upgraderV4: null, + ), Album( lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), name: "", @@ -208,8 +229,13 @@ void main() { }, }; expect( - Album.fromJson(json, - upgraderV1: null, upgraderV2: null, upgraderV3: null), + Album.fromJson( + json, + upgraderV1: null, + upgraderV2: null, + upgraderV3: null, + upgraderV4: null, + ), Album( lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), name: "", @@ -248,8 +274,13 @@ void main() { }, }; expect( - Album.fromJson(json, - upgraderV1: null, upgraderV2: null, upgraderV3: null), + Album.fromJson( + json, + upgraderV1: null, + upgraderV2: null, + upgraderV3: null, + upgraderV4: null, + ), Album( lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), name: "", @@ -287,8 +318,13 @@ void main() { }, }; expect( - Album.fromJson(json, - upgraderV1: null, upgraderV2: null, upgraderV3: null), + Album.fromJson( + json, + upgraderV1: null, + upgraderV2: null, + upgraderV3: null, + upgraderV4: null, + ), Album( lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), name: "", @@ -942,5 +978,374 @@ void main() { }, }); }); + + group("AlbumUpgraderV4", () { + test("Non AlbumFileItem", () { + final json = { + "version": 4, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [ + { + "type": "label", + "content": { + "text": "123", + }, + }, + ], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "time", + "content": { + "isAscending": false, + }, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(AlbumUpgraderV4()(json), { + "version": 4, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [ + { + "type": "label", + "content": { + "text": "123", + }, + }, + ], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "time", + "content": { + "isAscending": false, + }, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); + }); + + group("AlbumFileItem", () { + test("drop metadata", () { + final json = { + "version": 4, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [ + { + "type": "file", + "content": { + "file": { + "path": "remote.php/dav/files/admin/test1.jpg", + "metadata": { + "Make": "Super", + "Model": "A123", + }, + }, + }, + }, + ], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "time", + "content": { + "isAscending": false, + }, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(AlbumUpgraderV4()(json), { + "version": 4, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [ + { + "type": "file", + "content": { + "file": { + "path": "remote.php/dav/files/admin/test1.jpg", + }, + }, + }, + ], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "time", + "content": { + "isAscending": false, + }, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); + }); + + test("lastModified as latestItemTime", () { + final json = { + "version": 4, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [ + { + "type": "file", + "content": { + "file": { + "path": "remote.php/dav/files/admin/test1.jpg", + "lastModified": "2020-01-02T03:04:05.678901Z", + }, + }, + }, + ], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "time", + "content": { + "isAscending": false, + }, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(AlbumUpgraderV4()(json), { + "version": 4, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "latestItemTime": "2020-01-02T03:04:05.678901Z", + "items": [ + { + "type": "file", + "content": { + "file": { + "path": "remote.php/dav/files/admin/test1.jpg", + "lastModified": "2020-01-02T03:04:05.678901Z", + }, + }, + }, + ], + }, + }, + "coverProvider": { + "type": "auto", + "content": { + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "lastModified": "2020-01-02T03:04:05.678901Z", + }, + }, + }, + "sortProvider": { + "type": "time", + "content": { + "isAscending": false, + }, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); + }); + + test("dateTimeOriginal as latestItemTime", () { + final json = { + "version": 4, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [ + { + "type": "file", + "content": { + "file": { + "path": "remote.php/dav/files/admin/test1.jpg", + "metadata": { + "exif": { + // convert 2020-01-02T03:04:05Z to local time + "DateTimeOriginal": + DateFormat("yyyy:MM:dd HH:mm:ss").format( + DateTime.utc(2020, 1, 2, 3, 4, 5) + .toLocal()), + }, + }, + }, + }, + }, + ], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "time", + "content": { + "isAscending": false, + }, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(AlbumUpgraderV4()(json), { + "version": 4, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "latestItemTime": "2020-01-02T03:04:05.000Z", + "items": [ + { + "type": "file", + "content": { + "file": { + "path": "remote.php/dav/files/admin/test1.jpg", + }, + }, + }, + ], + }, + }, + "coverProvider": { + "type": "auto", + "content": { + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + }, + }, + }, + "sortProvider": { + "type": "time", + "content": { + "isAscending": false, + }, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); + }); + + test("overrideDateTime as latestItemTime", () { + final json = { + "version": 4, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "items": [ + { + "type": "file", + "content": { + "file": { + "path": "remote.php/dav/files/admin/test1.jpg", + "overrideDateTime": "2020-01-02T03:04:05.678901Z", + }, + }, + }, + ], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "time", + "content": { + "isAscending": false, + }, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }; + expect(AlbumUpgraderV4()(json), { + "version": 4, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "provider": { + "type": "static", + "content": { + "latestItemTime": "2020-01-02T03:04:05.678901Z", + "items": [ + { + "type": "file", + "content": { + "file": { + "path": "remote.php/dav/files/admin/test1.jpg", + "overrideDateTime": "2020-01-02T03:04:05.678901Z", + }, + }, + }, + ], + }, + }, + "coverProvider": { + "type": "auto", + "content": { + "coverFile": { + "path": "remote.php/dav/files/admin/test1.jpg", + "overrideDateTime": "2020-01-02T03:04:05.678901Z", + }, + }, + }, + "sortProvider": { + "type": "time", + "content": { + "isAscending": false, + }, + }, + "albumFile": { + "path": "remote.php/dav/files/admin/test1.json", + }, + }); + }); + }); + }); }); }