From 32bef588a1cbc36b6d28eb075db6a4ee68cf575b Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 26 May 2022 12:00:55 +0800 Subject: [PATCH] Support sorting albums by filename --- app/lib/entity/album.dart | 9 +- app/lib/entity/album/sort_provider.dart | 57 +++++ app/lib/entity/album/upgrader.dart | 22 ++ app/lib/l10n/app_en.arb | 8 + app/lib/l10n/untranslated-messages.txt | 22 ++ app/lib/widget/album_browser.dart | 36 +++ app/lib/widget/dynamic_album_browser.dart | 36 +++ app/test/entity/album/sort_provider_test.dart | 220 ++++++++++++++++++ app/test/entity/album_test.dart | 112 +++++++++ 9 files changed, 521 insertions(+), 1 deletion(-) diff --git a/app/lib/entity/album.dart b/app/lib/entity/album.dart index 62ac854e..d58dc9bd 100644 --- a/app/lib/entity/album.dart +++ b/app/lib/entity/album.dart @@ -98,6 +98,13 @@ class Album with EquatableMixin { return null; } } + if (jsonVersion < 8) { + result = upgraderFactory?.buildV7()?.call(result); + if (result == null) { + _log.info("[fromJson] Version $jsonVersion not compatible"); + return null; + } + } if (jsonVersion > version) { _log.warning( "[fromJson] Reading album with newer version: $jsonVersion > $version"); @@ -221,7 +228,7 @@ class Album with EquatableMixin { final int savedVersion; /// versioning of this class, use to upgrade old persisted album - static const version = 7; + static const version = 8; } class AlbumShare with EquatableMixin { diff --git a/app/lib/entity/album/sort_provider.dart b/app/lib/entity/album/sort_provider.dart index 8eadca33..28ecdfbd 100644 --- a/app/lib/entity/album/sort_provider.dart +++ b/app/lib/entity/album/sort_provider.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/entity/album/item.dart'; @@ -17,6 +18,9 @@ abstract class AlbumSortProvider with EquatableMixin { return AlbumNullSortProvider.fromJson(content.cast()); case AlbumTimeSortProvider._type: return AlbumTimeSortProvider.fromJson(content.cast()); + case AlbumFilenameSortProvider._type: + return AlbumFilenameSortProvider.fromJson( + content.cast()); default: _log.shout("[fromJson] Unknown type: $type"); throw ArgumentError.value(type, "type"); @@ -29,6 +33,8 @@ abstract class AlbumSortProvider with EquatableMixin { return AlbumNullSortProvider._type; } else if (this is AlbumTimeSortProvider) { return AlbumTimeSortProvider._type; + } else if (this is AlbumFilenameSortProvider) { + return AlbumFilenameSortProvider._type; } else { throw StateError("Unknwon subtype"); } @@ -157,3 +163,54 @@ class AlbumTimeSortProvider extends AlbumReversibleSortProvider { static const _type = "time"; } + +/// Sort based on the name of the files +class AlbumFilenameSortProvider extends AlbumReversibleSortProvider { + const AlbumFilenameSortProvider({ + required bool isAscending, + }) : super(isAscending: isAscending); + + factory AlbumFilenameSortProvider.fromJson(JsonObj json) { + return AlbumFilenameSortProvider( + isAscending: json["isAscending"] ?? true, + ); + } + + @override + toString() => "$runtimeType {" + "super: ${super.toString()}, " + "}"; + + @override + sort(List 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"; +} diff --git a/app/lib/entity/album/upgrader.dart b/app/lib/entity/album/upgrader.dart index bdc9615a..0b46328a 100644 --- a/app/lib/entity/album/upgrader.dart +++ b/app/lib/entity/album/upgrader.dart @@ -224,6 +224,24 @@ class AlbumUpgraderV6 implements AlbumUpgrader { final String? logFilePath; } +/// Upgrade v7 Album to v8 +class AlbumUpgraderV7 implements AlbumUpgrader { + const AlbumUpgraderV7({ + this.logFilePath, + }); + + @override + call(JsonObj json) { + _log.fine("[call] Upgrade v7 Album for file: $logFilePath"); + return json; + } + + static final _log = Logger("entity.album.upgrader.AlbumUpgraderV7"); + + /// File path for logging only + final String? logFilePath; +} + abstract class AlbumUpgraderFactory { const AlbumUpgraderFactory(); @@ -233,6 +251,7 @@ abstract class AlbumUpgraderFactory { AlbumUpgraderV4? buildV4(); AlbumUpgraderV5? buildV5(); AlbumUpgraderV6? buildV6(); + AlbumUpgraderV7? buildV7(); } class DefaultAlbumUpgraderFactory extends AlbumUpgraderFactory { @@ -264,6 +283,9 @@ class DefaultAlbumUpgraderFactory extends AlbumUpgraderFactory { @override buildV6() => AlbumUpgraderV6(logFilePath: logFilePath); + @override + buildV7() => AlbumUpgraderV7(logFilePath: logFilePath); + final Account account; final File? albumFile; diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 91a9baf9..2e50883f 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -607,6 +607,14 @@ "@sortOptionTimeDescendingLabel": { "description": "Sort by time, in descending order" }, + "sortOptionFilenameAscendingLabel": "Filename", + "@sortOptionFilenameAscendingLabel": { + "description": "Sort by filename, in ascending order" + }, + "sortOptionFilenameDescendingLabel": "Filename (descending)", + "@sortOptionFilenameDescendingLabel": { + "description": "Sort by filename, in descending order" + }, "sortOptionAlbumNameLabel": "Album name", "@sortOptionAlbumNameLabel": { "description": "Sort by album name, in ascending order" diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index edca28d4..f8e9a259 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -27,6 +27,8 @@ "settingsExperimentalDescription", "settingsExperimentalPageTitle", "rootPickerSkipConfirmationDialogContent2", + "sortOptionFilenameAscendingLabel", + "sortOptionFilenameDescendingLabel", "sortOptionManualLabel", "helpButtonLabel", "collectionSharingLabel", @@ -135,6 +137,8 @@ "settingsExperimentalPageTitle", "rootPickerSkipConfirmationDialogContent2", "timeSecondInputHint", + "sortOptionFilenameAscendingLabel", + "sortOptionFilenameDescendingLabel", "sortOptionManualLabel", "helpButtonLabel", "slideshowTooltip", @@ -275,6 +279,8 @@ "captureLogSuccessNotification", "rootPickerSkipConfirmationDialogContent2", "timeSecondInputHint", + "sortOptionFilenameAscendingLabel", + "sortOptionFilenameDescendingLabel", "sortOptionAlbumNameLabel", "sortOptionAlbumNameDescendingLabel", "sortOptionManualLabel", @@ -412,6 +418,8 @@ "settingsMiscellaneousPageTitle", "settingsPhotosTabSortByNameTitle", "rootPickerSkipConfirmationDialogContent2", + "sortOptionFilenameAscendingLabel", + "sortOptionFilenameDescendingLabel", "helpButtonLabel", "backgroundServiceStopping", "metadataTaskPauseLowBatteryNotification", @@ -433,6 +441,8 @@ "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsPhotosTabSortByNameTitle", + "sortOptionFilenameAscendingLabel", + "sortOptionFilenameDescendingLabel", "enhanceSuperResolution4xTitle", "enhanceStyleTransferTitle" ], @@ -446,6 +456,8 @@ "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsPhotosTabSortByNameTitle", + "sortOptionFilenameAscendingLabel", + "sortOptionFilenameDescendingLabel", "helpTooltip", "helpButtonLabel", "removeFromAlbumTooltip", @@ -471,6 +483,8 @@ "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsPhotosTabSortByNameTitle", + "sortOptionFilenameAscendingLabel", + "sortOptionFilenameDescendingLabel", "createCollectionTooltip", "createCollectionDialogAlbumLabel", "createCollectionDialogAlbumDescription", @@ -514,6 +528,8 @@ "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsPhotosTabSortByNameTitle", + "sortOptionFilenameAscendingLabel", + "sortOptionFilenameDescendingLabel", "enhanceTooltip", "enhanceButtonLabel", "enhanceIntroDialogTitle", @@ -536,6 +552,8 @@ "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsPhotosTabSortByNameTitle", + "sortOptionFilenameAscendingLabel", + "sortOptionFilenameDescendingLabel", "enhanceTooltip", "enhanceButtonLabel", "enhanceIntroDialogTitle", @@ -558,6 +576,8 @@ "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsPhotosTabSortByNameTitle", + "sortOptionFilenameAscendingLabel", + "sortOptionFilenameDescendingLabel", "enhanceTooltip", "enhanceButtonLabel", "enhanceIntroDialogTitle", @@ -580,6 +600,8 @@ "settingsMiscellaneousTitle", "settingsMiscellaneousPageTitle", "settingsPhotosTabSortByNameTitle", + "sortOptionFilenameAscendingLabel", + "sortOptionFilenameDescendingLabel", "enhanceTooltip", "enhanceButtonLabel", "enhanceIntroDialogTitle", diff --git a/app/lib/widget/album_browser.dart b/app/lib/widget/album_browser.dart index e4ac0048..1bc03cf2 100644 --- a/app/lib/widget/album_browser.dart +++ b/app/lib/widget/album_browser.dart @@ -575,6 +575,24 @@ class _AlbumBrowserState extends State Navigator.of(context).pop(); }, ), + FancyOptionPickerItem( + label: L10n.global().sortOptionFilenameAscendingLabel, + isSelected: sortProvider is AlbumFilenameSortProvider && + sortProvider.isAscending, + onSelect: () { + _onEditSortFilenamePressed(); + Navigator.of(context).pop(); + }, + ), + FancyOptionPickerItem( + label: L10n.global().sortOptionFilenameDescendingLabel, + isSelected: sortProvider is AlbumFilenameSortProvider && + !sortProvider.isAscending, + onSelect: () { + _onEditSortFilenameDescendingPressed(); + Navigator.of(context).pop(); + }, + ), if (sortProvider is AlbumNullSortProvider) FancyOptionPickerItem( label: L10n.global().sortOptionManualLabel, @@ -606,6 +624,24 @@ class _AlbumBrowserState extends State }); } + void _onEditSortFilenamePressed() { + _editAlbum = _editAlbum!.copyWith( + sortProvider: const AlbumFilenameSortProvider(isAscending: true), + ); + setState(() { + _transformItems(); + }); + } + + void _onEditSortFilenameDescendingPressed() { + _editAlbum = _editAlbum!.copyWith( + sortProvider: const AlbumFilenameSortProvider(isAscending: false), + ); + setState(() { + _transformItems(); + }); + } + void _onEditAddTextPressed() { showDialog( context: context, diff --git a/app/lib/widget/dynamic_album_browser.dart b/app/lib/widget/dynamic_album_browser.dart index 0310e1b2..ee6373a2 100644 --- a/app/lib/widget/dynamic_album_browser.dart +++ b/app/lib/widget/dynamic_album_browser.dart @@ -499,6 +499,24 @@ class _DynamicAlbumBrowserState extends State Navigator.of(context).pop(); }, ), + FancyOptionPickerItem( + label: L10n.global().sortOptionFilenameAscendingLabel, + isSelected: sortProvider is AlbumFilenameSortProvider && + sortProvider.isAscending, + onSelect: () { + _onEditSortFilenamePressed(); + Navigator.of(context).pop(); + }, + ), + FancyOptionPickerItem( + label: L10n.global().sortOptionFilenameDescendingLabel, + isSelected: sortProvider is AlbumFilenameSortProvider && + !sortProvider.isAscending, + onSelect: () { + _onEditSortFilenameDescendingPressed(); + Navigator.of(context).pop(); + }, + ), ], ), ); @@ -522,6 +540,24 @@ class _DynamicAlbumBrowserState extends State }); } + void _onEditSortFilenamePressed() { + _editAlbum = _editAlbum!.copyWith( + sortProvider: const AlbumFilenameSortProvider(isAscending: true), + ); + setState(() { + _transformItems(_sortedItems); + }); + } + + void _onEditSortFilenameDescendingPressed() { + _editAlbum = _editAlbum!.copyWith( + sortProvider: const AlbumFilenameSortProvider(isAscending: false), + ); + setState(() { + _transformItems(_sortedItems); + }); + } + Future _onAlbumUpdatedEvent(AlbumUpdatedEvent ev) async { if (ev.album.albumFile!.path == _album?.albumFile?.path) { final album = await _updateAlbumPostPopulate(ev.album, _sortedItems); diff --git a/app/test/entity/album/sort_provider_test.dart b/app/test/entity/album/sort_provider_test.dart index 79968b28..cf523c8e 100644 --- a/app/test/entity/album/sort_provider_test.dart +++ b/app/test/entity/album/sort_provider_test.dart @@ -27,6 +27,19 @@ void main() { test("head", _timeNonFileHead); }); }); + + group("AlbumFilenameSortProvider", () { + group("AlbumFileItem", () { + test("ascending", _filenameFileAscending); + test("descending", _filenameFileDescending); + test("natural", _filenameFileNatural); + }); + group("w/ non AlbumFileItem", () { + test("ascending", _filenameNonFileAscending); + test("descending", _filenameNonFileDescending); + test("head", _filenameNonFileHead); + }); + }); } void _timeFromJson() { @@ -217,3 +230,210 @@ void _timeNonFileHead() { const sort = AlbumTimeSortProvider(isAscending: true); expect(sort.sort(items), [items[0], items[2], items[1], items[3]]); } + +/// Sort files by filename +/// +/// Expect: items sorted +void _filenameFileAscending() { + final items = (util.FilesBuilder() + ..addJpeg( + "admin/test3.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), + ) + ..addJpeg( + "admin/test1.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), + ) + ..addJpeg( + "admin/test2.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), + )) + .build() + .mapWithIndex((i, f) => AlbumFileItem( + addedBy: CiString("admin"), + addedAt: f.lastModified!, + file: f, + )) + .toList(); + const sort = AlbumFilenameSortProvider(isAscending: true); + expect(sort.sort(items), [items[1], items[2], items[0]]); +} + +/// Sort files by filename, descending +/// +/// Expect: items sorted +void _filenameFileDescending() { + final items = (util.FilesBuilder() + ..addJpeg( + "admin/test3.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), + ) + ..addJpeg( + "admin/test1.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), + ) + ..addJpeg( + "admin/test2.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), + )) + .build() + .mapWithIndex((i, f) => AlbumFileItem( + addedBy: CiString("admin"), + addedAt: f.lastModified!, + file: f, + )) + .toList(); + const sort = AlbumFilenameSortProvider(isAscending: false); + expect(sort.sort(items), [items[0], items[2], items[1]]); +} + +/// Sort files by filename +/// +/// Expect: items sorted in natural order +void _filenameFileNatural() { + final items = (util.FilesBuilder() + ..addJpeg( + "admin/test033_2.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), + ) + ..addJpeg( + "admin/test033_1.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), + ) + ..addJpeg( + "admin/test033_3.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), + ) + ..addJpeg( + "admin/test11.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), + ) + ..addJpeg( + "admin/test2.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), + ) + ..addJpeg( + "admin/test2_999.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), + )) + .build() + .mapWithIndex((i, f) => AlbumFileItem( + addedBy: CiString("admin"), + addedAt: f.lastModified!, + file: f, + )) + .toList(); + const sort = AlbumFilenameSortProvider(isAscending: true); + expect( + sort.sort(items), + [items[4], items[5], items[3], items[1], items[0], items[2]], + ); +} + +/// Sort files + non files by filename +/// +/// Expect: file sorted, non file stick with the prev file +void _filenameNonFileAscending() { + final items = (util.FilesBuilder() + ..addJpeg( + "admin/test3.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), + ) + ..addJpeg( + "admin/test1.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), + ) + ..addJpeg( + "admin/test2.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), + )) + .build() + .mapWithIndex((i, f) => AlbumFileItem( + addedBy: CiString("admin"), + addedAt: f.lastModified!, + file: f, + )) + .toList(); + items.insert( + 2, + AlbumLabelItem( + addedBy: CiString("admin"), + addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), + text: "test", + ), + ); + const sort = AlbumFilenameSortProvider(isAscending: true); + expect(sort.sort(items), [items[1], items[2], items[3], items[0]]); +} + +/// Sort files + non files by filename, descending +/// +/// Expect: file sorted, non file stick with the prev file +void _filenameNonFileDescending() { + final items = (util.FilesBuilder() + ..addJpeg( + "admin/test3.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), + ) + ..addJpeg( + "admin/test1.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), + ) + ..addJpeg( + "admin/test2.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), + )) + .build() + .mapWithIndex((i, f) => AlbumFileItem( + addedBy: CiString("admin"), + addedAt: f.lastModified!, + file: f, + )) + .toList(); + items.insert( + 2, + AlbumLabelItem( + addedBy: CiString("admin"), + addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), + text: "test", + ), + ); + const sort = AlbumFilenameSortProvider(isAscending: false); + expect(sort.sort(items), [items[0], items[3], items[1], items[2]]); +} + +/// Sort files + non files by filename, with the head being a non file +/// +/// Expect: file sorted, non file stick at the head +void _filenameNonFileHead() { + final items = (util.FilesBuilder() + ..addJpeg( + "admin/test3.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 1), + ) + ..addJpeg( + "admin/test1.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 0), + ) + ..addJpeg( + "admin/test2.jpg", + lastModified: DateTime.utc(2020, 1, 2, 3, 4, 2), + )) + .build() + .mapWithIndex((i, f) => AlbumFileItem( + addedBy: CiString("admin"), + addedAt: f.lastModified!, + file: f, + )) + .toList(); + items.insert( + 0, + AlbumLabelItem( + addedBy: CiString("admin"), + addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5), + text: "test", + ), + ); + const sort = AlbumFilenameSortProvider(isAscending: true); + expect(sort.sort(items), [items[0], items[2], items[3], items[1]]); +} diff --git a/app/test/entity/album_test.dart b/app/test/entity/album_test.dart index 7988b9a0..dc13b723 100644 --- a/app/test/entity/album_test.dart +++ b/app/test/entity/album_test.dart @@ -292,6 +292,46 @@ void main() { )); }); + test("AlbumFilenameSortProvider", () { + final json = { + "version": Album.version, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "name": "", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "filename", + "content": { + "isAscending": true, + }, + }, + }; + expect( + Album.fromJson( + json, + upgraderFactory: const _NullAlbumUpgraderFactory(), + ), + Album( + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), + name: "", + provider: AlbumStaticProvider( + items: [], + ), + coverProvider: AlbumAutoCoverProvider(), + sortProvider: const AlbumFilenameSortProvider( + isAscending: true, + ), + )); + }); + test("shares", _fromJsonShares); test("albumFile", () { @@ -580,6 +620,41 @@ void main() { }); }); + test("AlbumFilenameSortProvider", () { + final album = Album( + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), + name: "", + provider: AlbumStaticProvider( + items: [], + ), + coverProvider: AlbumAutoCoverProvider(), + sortProvider: const AlbumFilenameSortProvider( + isAscending: true, + ), + ); + expect(album.toAppDbJson(), { + "version": Album.version, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "name": "", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "filename", + "content": { + "isAscending": true, + }, + }, + }); + }); + test("shares", _toRemoteJsonShares); }); @@ -831,6 +906,41 @@ void main() { }); }); + test("AlbumFilenameSortProvider", () { + final album = Album( + lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678, 901), + name: "", + provider: AlbumStaticProvider( + items: [], + ), + coverProvider: AlbumAutoCoverProvider(), + sortProvider: const AlbumFilenameSortProvider( + isAscending: true, + ), + ); + expect(album.toAppDbJson(), { + "version": Album.version, + "lastUpdated": "2020-01-02T03:04:05.678901Z", + "name": "", + "provider": { + "type": "static", + "content": { + "items": [], + }, + }, + "coverProvider": { + "type": "auto", + "content": {}, + }, + "sortProvider": { + "type": "filename", + "content": { + "isAscending": true, + }, + }, + }); + }); + test("shares", _toAppDbJsonShares); test("albumFile", () { @@ -1788,4 +1898,6 @@ class _NullAlbumUpgraderFactory extends AlbumUpgraderFactory { buildV5() => null; @override buildV6() => null; + @override + buildV7() => null; }