From a846a51332edc37f6ec7e076066d556d85d3ed5d Mon Sep 17 00:00:00 2001
From: Ming Ming <nkming2@gmail.com>
Date: Sat, 29 May 2021 01:15:09 +0800
Subject: [PATCH] Treat metadata as property

---
 lib/bloc/list_album.dart                      | 16 +++--
 lib/bloc/scan_dir.dart                        | 34 ++++++-----
 lib/entity/file.dart                          | 23 ++++---
 lib/entity/file/data_source.dart              | 49 +++++++++++----
 lib/event/event.dart                          | 13 +++-
 lib/or_null.dart                              |  2 +
 lib/use_case/update_missing_metadata.dart     | 12 +++-
 ...ate_metadata.dart => update_property.dart} | 61 +++++++++++++++----
 8 files changed, 153 insertions(+), 57 deletions(-)
 rename lib/use_case/{update_metadata.dart => update_property.dart} (51%)

diff --git a/lib/bloc/list_album.dart b/lib/bloc/list_album.dart
index fbd143af..218eb622 100644
--- a/lib/bloc/list_album.dart
+++ b/lib/bloc/list_album.dart
@@ -89,15 +89,15 @@ class ListAlbumBlocInconsistent extends ListAlbumBlocState {
 
 class ListAlbumBloc extends Bloc<ListAlbumBlocEvent, ListAlbumBlocState> {
   ListAlbumBloc() : super(ListAlbumBlocInit()) {
-    _fileMetadataUpdatedListener =
-        AppEventListener<FileMetadataUpdatedEvent>(_onFileMetadataUpdatedEvent);
+    _filePropertyUpdatedListener =
+        AppEventListener<FilePropertyUpdatedEvent>(_onFilePropertyUpdatedEvent);
     _albumUpdatedListener =
         AppEventListener<AlbumUpdatedEvent>(_onAlbumUpdatedEvent);
     _fileRemovedListener =
         AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
     _albumCreatedListener =
         AppEventListener<AlbumCreatedEvent>(_onAlbumCreatedEvent);
-    _fileMetadataUpdatedListener.begin();
+    _filePropertyUpdatedListener.begin();
     _albumUpdatedListener.begin();
     _fileRemovedListener.begin();
     _albumCreatedListener.begin();
@@ -115,7 +115,7 @@ class ListAlbumBloc extends Bloc<ListAlbumBlocEvent, ListAlbumBlocState> {
 
   @override
   close() {
-    _fileMetadataUpdatedListener.end();
+    _filePropertyUpdatedListener.end();
     _albumUpdatedListener.end();
     _fileRemovedListener.end();
     _albumCreatedListener.end();
@@ -160,7 +160,11 @@ class ListAlbumBloc extends Bloc<ListAlbumBlocEvent, ListAlbumBlocState> {
     yield ListAlbumBlocInconsistent(state.account, state.albums);
   }
 
-  void _onFileMetadataUpdatedEvent(FileMetadataUpdatedEvent ev) {
+  void _onFilePropertyUpdatedEvent(FilePropertyUpdatedEvent ev) {
+    if (!ev.hasAnyProperties([FilePropertyUpdatedEvent.propMetadata])) {
+      // not interested
+      return;
+    }
     if (state is ListAlbumBlocInit) {
       // no data in this bloc, ignore
       return;
@@ -220,7 +224,7 @@ class ListAlbumBloc extends Bloc<ListAlbumBlocEvent, ListAlbumBlocState> {
     }
   }
 
-  AppEventListener<FileMetadataUpdatedEvent> _fileMetadataUpdatedListener;
+  AppEventListener<FilePropertyUpdatedEvent> _filePropertyUpdatedListener;
   AppEventListener<AlbumUpdatedEvent> _albumUpdatedListener;
   AppEventListener<FileRemovedEvent> _fileRemovedListener;
   AppEventListener<AlbumCreatedEvent> _albumCreatedListener;
diff --git a/lib/bloc/scan_dir.dart b/lib/bloc/scan_dir.dart
index 4b4b4b83..42768743 100644
--- a/lib/bloc/scan_dir.dart
+++ b/lib/bloc/scan_dir.dart
@@ -118,10 +118,10 @@ class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
   ScanDirBloc() : super(ScanDirBlocInit()) {
     _fileRemovedEventListener =
         AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
-    _fileMetadataUpdatedEventListener =
-        AppEventListener<FileMetadataUpdatedEvent>(_onFileMetadataUpdatedEvent);
+    _filePropertyUpdatedEventListener =
+        AppEventListener<FilePropertyUpdatedEvent>(_onFilePropertyUpdatedEvent);
     _fileRemovedEventListener.begin();
-    _fileMetadataUpdatedEventListener.begin();
+    _filePropertyUpdatedEventListener.begin();
   }
 
   static ScanDirBloc of(Account account) {
@@ -165,8 +165,8 @@ class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
   @override
   close() {
     _fileRemovedEventListener.end();
-    _fileMetadataUpdatedEventListener.end();
-    _metadataUpdatedSubscription?.cancel();
+    _filePropertyUpdatedEventListener.end();
+    _propertyUpdatedSubscription?.cancel();
     return super.close();
   }
 
@@ -215,22 +215,28 @@ class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
     add(_ScanDirBlocExternalEvent());
   }
 
-  void _onFileMetadataUpdatedEvent(FileMetadataUpdatedEvent ev) {
+  void _onFilePropertyUpdatedEvent(FilePropertyUpdatedEvent ev) {
+    if (!ev.hasAnyProperties([
+      FilePropertyUpdatedEvent.propMetadata,
+    ])) {
+      // not interested
+      return;
+    }
     if (state is ScanDirBlocInit) {
       // no data in this bloc, ignore
       return;
     }
 
-    _successiveMetadataUpdatedCount += 1;
-    _metadataUpdatedSubscription?.cancel();
+    _successivePropertyUpdatedCount += 1;
+    _propertyUpdatedSubscription?.cancel();
     // only trigger the event on the 10th update or 10s after the last update
-    if (_successiveMetadataUpdatedCount % 10 == 0) {
+    if (_successivePropertyUpdatedCount % 10 == 0) {
       add(_ScanDirBlocExternalEvent());
     } else {
-      _metadataUpdatedSubscription =
+      _propertyUpdatedSubscription =
           Future.delayed(const Duration(seconds: 10)).asStream().listen((_) {
         add(_ScanDirBlocExternalEvent());
-        _successiveMetadataUpdatedCount = 0;
+        _successivePropertyUpdatedCount = 0;
       });
     }
   }
@@ -267,10 +273,10 @@ class ScanDirBloc extends Bloc<ScanDirBlocEvent, ScanDirBlocState> {
   }
 
   AppEventListener<FileRemovedEvent> _fileRemovedEventListener;
-  AppEventListener<FileMetadataUpdatedEvent> _fileMetadataUpdatedEventListener;
+  AppEventListener<FilePropertyUpdatedEvent> _filePropertyUpdatedEventListener;
 
-  int _successiveMetadataUpdatedCount = 0;
-  StreamSubscription<void> _metadataUpdatedSubscription;
+  int _successivePropertyUpdatedCount = 0;
+  StreamSubscription<void> _propertyUpdatedSubscription;
 
   bool _shouldCheckCache = true;
 
diff --git a/lib/entity/file.dart b/lib/entity/file.dart
index bbf1d298..af31ee97 100644
--- a/lib/entity/file.dart
+++ b/lib/entity/file.dart
@@ -366,8 +366,16 @@ class FileRepo {
       this.dataSrc.putBinary(account, path, content);
 
   /// See [FileDataSource.updateMetadata]
-  Future<void> updateMetadata(Account account, File file, Metadata metadata) =>
-      this.dataSrc.updateMetadata(account, file, metadata);
+  Future<void> updateProperty(
+    Account account,
+    File file, {
+    OrNull<Metadata> metadata,
+  }) =>
+      this.dataSrc.updateProperty(
+            account,
+            file,
+            metadata: metadata,
+          );
 
   /// See [FileDataSource.copy]
   Future<void> copy(
@@ -417,11 +425,12 @@ abstract class FileDataSource {
   /// Upload content to [path]
   Future<void> putBinary(Account account, String path, Uint8List content);
 
-  /// Update metadata for a file
-  ///
-  /// This will completely replace the metadata of the file [f]. Partial update
-  /// is not supported
-  Future<void> updateMetadata(Account account, File f, Metadata metadata);
+  /// Update one or more properties of a file
+  Future<void> updateProperty(
+    Account account,
+    File f, {
+    OrNull<Metadata> metadata,
+  });
 
   /// Copy [f] to [destination]
   ///
diff --git a/lib/entity/file/data_source.dart b/lib/entity/file/data_source.dart
index b9de38c4..7976a196 100644
--- a/lib/entity/file/data_source.dart
+++ b/lib/entity/file/data_source.dart
@@ -105,17 +105,22 @@ class FileWebdavDataSource implements FileDataSource {
   }
 
   @override
-  updateMetadata(Account account, File f, Metadata metadata) async {
-    _log.info("[updateMetadata] ${f.path}");
-    if (metadata != null && metadata.fileEtag != f.etag) {
+  updateProperty(
+    Account account,
+    File f, {
+    OrNull<Metadata> metadata,
+  }) async {
+    _log.info("[updateProperty] ${f.path}");
+    if (metadata?.obj != null && metadata.obj.fileEtag != f.etag) {
       _log.warning(
-          "[updateMetadata] etag mismatch (metadata: ${metadata.fileEtag}, file: ${f.etag})");
+          "[updateProperty] Metadata etag mismatch (metadata: ${metadata.obj.fileEtag}, file: ${f.etag})");
     }
     final setProps = {
-      if (metadata != null) "app:metadata": jsonEncode(metadata.toJson()),
+      if (metadata?.obj != null)
+        "app:metadata": jsonEncode(metadata.obj.toJson()),
     };
     final removeProps = [
-      if (metadata == null) "app:metadata",
+      if (OrNull.isNull(metadata)) "app:metadata",
     ];
     final response = await Api(account).files().proppatch(
           path: f.path,
@@ -126,7 +131,7 @@ class FileWebdavDataSource implements FileDataSource {
           remove: removeProps.isNotEmpty ? removeProps : null,
         );
     if (!response.isGood) {
-      _log.severe("[updateMetadata] Failed requesting server: $response");
+      _log.severe("[updateProperty] Failed requesting server: $response");
       throw ApiException(
           response: response,
           message: "Failed communicating with server: ${response.statusCode}");
@@ -236,8 +241,12 @@ class FileAppDbDataSource implements FileDataSource {
   }
 
   @override
-  updateMetadata(Account account, File f, Metadata metadata) {
-    _log.info("[updateMetadata] ${f.path}");
+  updateProperty(
+    Account account,
+    File f, {
+    OrNull<Metadata> metadata,
+  }) {
+    _log.info("[updateProperty] ${f.path}");
     return AppDb.use((db) async {
       final transaction = db.transaction(AppDb.fileStoreName, idbModeReadWrite);
       final store = transaction.objectStore(AppDb.fileStoreName);
@@ -245,7 +254,9 @@ class FileAppDbDataSource implements FileDataSource {
       final parentList = await _doList(store, account, parentDir);
       final jsonList = parentList.map((e) {
         if (e.path == f.path) {
-          return e.copyWith(metadata: OrNull(metadata));
+          return e.copyWith(
+            metadata: metadata,
+          );
         } else {
           return e;
         }
@@ -369,10 +380,22 @@ class FileCachedDataSource implements FileDataSource {
   }
 
   @override
-  updateMetadata(Account account, File f, Metadata metadata) async {
+  updateProperty(
+    Account account,
+    File f, {
+    OrNull<Metadata> metadata,
+  }) async {
     await _remoteSrc
-        .updateMetadata(account, f, metadata)
-        .then((_) => _appDbSrc.updateMetadata(account, f, metadata));
+        .updateProperty(
+          account,
+          f,
+          metadata: metadata,
+        )
+        .then((_) => _appDbSrc.updateProperty(
+              account,
+              f,
+              metadata: metadata,
+            ));
 
     // generate a new random token
     final token = Uuid().v4().replaceAll("-", "");
diff --git a/lib/event/event.dart b/lib/event/event.dart
index b0ec26b7..cf8ffb18 100644
--- a/lib/event/event.dart
+++ b/lib/event/event.dart
@@ -48,11 +48,15 @@ class AlbumUpdatedEvent {
   final Album album;
 }
 
-class FileMetadataUpdatedEvent {
-  FileMetadataUpdatedEvent(this.account, this.file);
+class FilePropertyUpdatedEvent {
+  FilePropertyUpdatedEvent(this.account, this.file, this.properties);
 
   final Account account;
   final File file;
+  final int properties;
+
+  // Bit masks for properties field
+  static const propMetadata = 0x01;
 }
 
 class FileRemovedEvent {
@@ -63,3 +67,8 @@ class FileRemovedEvent {
 }
 
 class ThemeChangedEvent {}
+
+extension FilePropertyUpdatedEventExtension on FilePropertyUpdatedEvent {
+  bool hasAnyProperties(List<int> properties) =>
+      properties.any((p) => this.properties & p != 0);
+}
diff --git a/lib/or_null.dart b/lib/or_null.dart
index e3b816dc..faef3c7d 100644
--- a/lib/or_null.dart
+++ b/lib/or_null.dart
@@ -2,5 +2,7 @@
 class OrNull<T> {
   OrNull(this.obj);
 
+  static bool isNull(OrNull x) => x != null && x.obj == null;
+
   final T obj;
 }
diff --git a/lib/use_case/update_missing_metadata.dart b/lib/use_case/update_missing_metadata.dart
index e6eaaae1..6715e308 100644
--- a/lib/use_case/update_missing_metadata.dart
+++ b/lib/use_case/update_missing_metadata.dart
@@ -7,8 +7,9 @@ import 'package:nc_photos/entity/file.dart';
 import 'package:nc_photos/entity/file/data_source.dart';
 import 'package:nc_photos/mobile/platform.dart'
     if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
+import 'package:nc_photos/or_null.dart';
 import 'package:nc_photos/use_case/scan_missing_metadata.dart';
-import 'package:nc_photos/use_case/update_metadata.dart';
+import 'package:nc_photos/use_case/update_property.dart';
 
 class UpdateMissingMetadata {
   UpdateMissingMetadata(this.fileRepo);
@@ -51,8 +52,13 @@ class UpdateMissingMetadata {
           exif: exif,
         );
 
-        await UpdateMetadata(FileRepo(FileCachedDataSource()),
-            AlbumRepo(AlbumCachedDataSource()))(account, file, metadataObj);
+        final updateOp = UpdateProperty(FileRepo(FileCachedDataSource()),
+            AlbumRepo(AlbumCachedDataSource()));
+        await updateOp(
+          account,
+          file,
+          metadata: OrNull(metadataObj),
+        );
         yield file;
       } catch (e, stacktrace) {
         _log.shout(
diff --git a/lib/use_case/update_metadata.dart b/lib/use_case/update_property.dart
similarity index 51%
rename from lib/use_case/update_metadata.dart
rename to lib/use_case/update_property.dart
index ba0ceb7a..7e1b2b61 100644
--- a/lib/use_case/update_metadata.dart
+++ b/lib/use_case/update_property.dart
@@ -9,23 +9,50 @@ import 'package:nc_photos/or_null.dart';
 import 'package:nc_photos/use_case/list_album.dart';
 import 'package:nc_photos/use_case/update_album.dart';
 
-class UpdateMetadata {
-  UpdateMetadata(this.fileRepo, this.albumRepo);
+class UpdateProperty {
+  UpdateProperty(this.fileRepo, this.albumRepo);
 
-  Future<void> call(Account account, File file, Metadata metadata) async {
-    if (metadata != null && metadata.fileEtag != file.etag) {
-      _log.warning(
-          "[call] Metadata fileEtag mismatch with actual file's (metadata: ${metadata.fileEtag}, file: ${file.etag})");
+  Future<void> call(
+    Account account,
+    File file, {
+    OrNull<Metadata> metadata,
+  }) async {
+    if (metadata == null) {
+      // ?
+      _log.warning("[call] Nothing to update");
+      return;
     }
-    await fileRepo.updateMetadata(account, file, metadata);
-    await _cleanUpAlbums(account, file, metadata);
+
+    if (metadata?.obj != null && metadata.obj.fileEtag != file.etag) {
+      _log.warning(
+          "[call] Metadata fileEtag mismatch with actual file's (metadata: ${metadata.obj.fileEtag}, file: ${file.etag})");
+    }
+    await fileRepo.updateProperty(
+      account,
+      file,
+      metadata: metadata,
+    );
+    await _cleanUpAlbums(
+      account,
+      file,
+      metadata: metadata,
+    );
+
+    int properties = 0;
+    if (metadata != null) {
+      properties |= FilePropertyUpdatedEvent.propMetadata;
+    }
+    assert(properties != 0);
     KiwiContainer()
         .resolve<EventBus>()
-        .fire(FileMetadataUpdatedEvent(account, file));
+        .fire(FilePropertyUpdatedEvent(account, file, properties));
   }
 
   Future<void> _cleanUpAlbums(
-      Account account, File file, Metadata metadata) async {
+    Account account,
+    File file, {
+    OrNull<Metadata> metadata,
+  }) async {
     final albums = await ListAlbum(fileRepo, albumRepo)(account);
     for (final a in albums) {
       try {
@@ -34,7 +61,9 @@ class UpdateMetadata {
           final newItems = a.items.map((e) {
             if (e is AlbumFileItem && e.file.path == file.path) {
               return AlbumFileItem(
-                file: e.file.copyWith(metadata: OrNull(metadata)),
+                file: e.file.copyWith(
+                  metadata: metadata,
+                ),
               );
             } else {
               return e;
@@ -53,5 +82,13 @@ class UpdateMetadata {
   final FileRepo fileRepo;
   final AlbumRepo albumRepo;
 
-  static final _log = Logger("use_case.update_metadata.UpdateMetadata");
+  static final _log = Logger("use_case.update_property.UpdateProperty");
+}
+
+extension UpdatePropertyExtension on UpdateProperty {
+  /// Convenience function to only update metadata
+  ///
+  /// See [UpdateProperty.call]
+  Future<void> updateMetadata(Account account, File file, Metadata metadata) =>
+      call(account, file, metadata: OrNull(metadata));
 }