From 738883387aed01bc041848be82357c9f78ac405f Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 16 Jul 2023 19:30:23 +0800 Subject: [PATCH] Support face provided by Recognize app --- app/lib/api/entity_converter.dart | 46 + app/lib/app_init.dart | 8 + app/lib/di_container.dart | 43 + app/lib/entity/person.dart | 10 + app/lib/entity/person/adapter.dart | 4 + app/lib/entity/person/adapter/recognize.dart | 47 + .../entity/person/adapter/recognize.g.dart | 15 + app/lib/entity/person/builder.dart | 15 + .../content_provider/face_recognition.dart | 4 + .../person/content_provider/recognize.dart | 111 +++ .../person/content_provider/recognize.g.dart | 14 + app/lib/entity/recognize_face.dart | 26 + app/lib/entity/recognize_face.g.dart | 14 + .../entity/recognize_face/data_source.dart | 212 ++++ .../entity/recognize_face/data_source.g.dart | 25 + app/lib/entity/recognize_face/repo.dart | 75 ++ app/lib/entity/recognize_face/repo.g.dart | 15 + app/lib/entity/recognize_face_item.dart | 113 +++ app/lib/entity/recognize_face_item.g.dart | 14 + app/lib/entity/sqlite/database.dart | 4 + app/lib/entity/sqlite/database.g.dart | 935 +++++++++++++++++- app/lib/entity/sqlite/database_extension.dart | 86 ++ app/lib/entity/sqlite/table.dart | 41 + app/lib/entity/sqlite/table.g.dart | 104 ++ app/lib/entity/sqlite/type_converter.dart | 81 ++ .../sync_face_recognition_person.dart | 14 +- .../recognize_face/list_recognize_face.dart | 13 + .../list_recognize_face_item.dart | 29 + .../recognize_face/sync_recognize_face.dart | 288 ++++++ .../recognize_face/sync_recognize_face.g.dart | 15 + app/lib/widget/network_thumbnail.dart | 58 +- app/lib/widget/search_landing.dart | 176 ++-- app/lib/widget/search_landing/type.dart | 49 + app/lib/widget/search_landing/view.dart | 143 +++ app/test/api/entity_converter_test.dart | 41 + np_api/lib/np_api.dart | 1 + np_api/lib/src/entity/entity.dart | 3 +- .../entity/recognize_face_item_parser.dart | 15 +- np_api/lib/src/recognize_api.dart | 8 +- .../recognize_face_item_parser_test.dart | 45 +- 40 files changed, 2839 insertions(+), 121 deletions(-) create mode 100644 app/lib/entity/person/adapter/recognize.dart create mode 100644 app/lib/entity/person/adapter/recognize.g.dart create mode 100644 app/lib/entity/person/content_provider/recognize.dart create mode 100644 app/lib/entity/person/content_provider/recognize.g.dart create mode 100644 app/lib/entity/recognize_face.dart create mode 100644 app/lib/entity/recognize_face.g.dart create mode 100644 app/lib/entity/recognize_face/data_source.dart create mode 100644 app/lib/entity/recognize_face/data_source.g.dart create mode 100644 app/lib/entity/recognize_face/repo.dart create mode 100644 app/lib/entity/recognize_face/repo.g.dart create mode 100644 app/lib/entity/recognize_face_item.dart create mode 100644 app/lib/entity/recognize_face_item.g.dart create mode 100644 app/lib/entity/sqlite/table.g.dart create mode 100644 app/lib/use_case/recognize_face/list_recognize_face.dart create mode 100644 app/lib/use_case/recognize_face/list_recognize_face_item.dart create mode 100644 app/lib/use_case/recognize_face/sync_recognize_face.dart create mode 100644 app/lib/use_case/recognize_face/sync_recognize_face.g.dart create mode 100644 app/lib/widget/search_landing/type.dart create mode 100644 app/lib/widget/search_landing/view.dart diff --git a/app/lib/api/entity_converter.dart b/app/lib/api/entity_converter.dart index ab4c8d84..44e918f2 100644 --- a/app/lib/api/entity_converter.dart +++ b/app/lib/api/entity_converter.dart @@ -7,6 +7,8 @@ import 'package:nc_photos/entity/favorite.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/nc_album.dart'; import 'package:nc_photos/entity/nc_album_item.dart'; +import 'package:nc_photos/entity/recognize_face.dart'; +import 'package:nc_photos/entity/recognize_face_item.dart'; import 'package:nc_photos/entity/server_status.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/sharee.dart'; @@ -131,6 +133,50 @@ class ApiNcAlbumItemConverter { } } +class ApiRecognizeFaceConverter { + static RecognizeFace fromApi(api.RecognizeFace item) { + // remote.php/dav/recognize/admin/faces/john + var path = _hrefToPath(item.href); + if (!path.startsWith("remote.php/dav/recognize/")) { + throw ArgumentError("Invalid face path: ${item.href}"); + } + // admin/faces/john + path = path.substring(25); + final found = path.indexOf("/"); + if (found == -1) { + throw ArgumentError("Invalid face path: ${item.href}"); + } + // faces/john + path = path.substring(found + 1); + if (!path.startsWith("faces")) { + throw ArgumentError("Invalid face path: ${item.href}"); + } + // john + path = path.slice(6); + return RecognizeFace(label: path); + } +} + +class ApiRecognizeFaceItemConverter { + static RecognizeFaceItem fromApi(api.RecognizeFaceItem item) { + return RecognizeFaceItem( + path: _hrefToPath(item.href), + fileId: item.fileId!, + contentLength: item.contentLength, + contentType: item.contentType, + etag: item.etag, + lastModified: item.lastModified, + hasPreview: item.hasPreview, + realPath: item.realPath, + isFavorite: item.favorite, + fileMetadataWidth: item.fileMetadataSize?["width"], + fileMetadataHeight: item.fileMetadataSize?["height"], + faceDetections: + item.faceDetections?.isEmpty == true ? null : item.faceDetections, + ); + } +} + class ApiShareConverter { static Share fromApi(api.Share share) { final shareType = ShareTypeExtension.fromValue(share.shareType); diff --git a/app/lib/app_init.dart b/app/lib/app_init.dart index 059c5c8a..dfe42504 100644 --- a/app/lib/app_init.dart +++ b/app/lib/app_init.dart @@ -23,6 +23,8 @@ import 'package:nc_photos/entity/nc_album/repo.dart'; import 'package:nc_photos/entity/pref.dart'; import 'package:nc_photos/entity/pref/provider/shared_preferences.dart'; import 'package:nc_photos/entity/pref_util.dart' as pref_util; +import 'package:nc_photos/entity/recognize_face/data_source.dart'; +import 'package:nc_photos/entity/recognize_face/repo.dart'; import 'package:nc_photos/entity/search.dart'; import 'package:nc_photos/entity/search/data_source.dart'; import 'package:nc_photos/entity/share.dart'; @@ -228,6 +230,12 @@ Future _initDiContainer(InitIsolateType isolateType) async { FaceRecognitionPersonRemoteDataSource()); c.faceRecognitionPersonRepoLocal = BasicFaceRecognitionPersonRepo( FaceRecognitionPersonSqliteDbDataSource(c.sqliteDb)); + c.recognizeFaceRepo = + const BasicRecognizeFaceRepo(RecognizeFaceRemoteDataSource()); + c.recognizeFaceRepoRemote = + const BasicRecognizeFaceRepo(RecognizeFaceRemoteDataSource()); + c.recognizeFaceRepoLocal = + BasicRecognizeFaceRepo(RecognizeFaceSqliteDbDataSource(c.sqliteDb)); c.touchManager = TouchManager(c); diff --git a/app/lib/di_container.dart b/app/lib/di_container.dart index a2070343..8f3623dc 100644 --- a/app/lib/di_container.dart +++ b/app/lib/di_container.dart @@ -6,6 +6,7 @@ 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/pref.dart'; +import 'package:nc_photos/entity/recognize_face/repo.dart'; import 'package:nc_photos/entity/search.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/sharee.dart'; @@ -40,6 +41,9 @@ enum DiType { faceRecognitionPersonRepo, faceRecognitionPersonRepoRemote, faceRecognitionPersonRepoLocal, + recognizeFaceRepo, + recognizeFaceRepoRemote, + recognizeFaceRepoLocal, pref, sqliteDb, touchManager, @@ -71,6 +75,9 @@ class DiContainer { FaceRecognitionPersonRepo? faceRecognitionPersonRepo, FaceRecognitionPersonRepo? faceRecognitionPersonRepoRemote, FaceRecognitionPersonRepo? faceRecognitionPersonRepoLocal, + RecognizeFaceRepo? recognizeFaceRepo, + RecognizeFaceRepo? recognizeFaceRepoRemote, + RecognizeFaceRepo? recognizeFaceRepoLocal, Pref? pref, sql.SqliteDb? sqliteDb, TouchManager? touchManager, @@ -98,6 +105,9 @@ class DiContainer { _faceRecognitionPersonRepo = faceRecognitionPersonRepo, _faceRecognitionPersonRepoRemote = faceRecognitionPersonRepoRemote, _faceRecognitionPersonRepoLocal = faceRecognitionPersonRepoLocal, + _recognizeFaceRepo = recognizeFaceRepo, + _recognizeFaceRepoRemote = recognizeFaceRepoRemote, + _recognizeFaceRepoLocal = recognizeFaceRepoLocal, _pref = pref, _sqliteDb = sqliteDb, _touchManager = touchManager; @@ -154,6 +164,12 @@ class DiContainer { return contianer._faceRecognitionPersonRepoRemote != null; case DiType.faceRecognitionPersonRepoLocal: return contianer._faceRecognitionPersonRepoLocal != null; + case DiType.recognizeFaceRepo: + return contianer._recognizeFaceRepo != null; + case DiType.recognizeFaceRepoRemote: + return contianer._recognizeFaceRepoRemote != null; + case DiType.recognizeFaceRepoLocal: + return contianer._recognizeFaceRepoLocal != null; case DiType.pref: return contianer._pref != null; case DiType.sqliteDb: @@ -176,6 +192,7 @@ class DiContainer { OrNull? searchRepo, OrNull? ncAlbumRepo, OrNull? faceRecognitionPersonRepo, + OrNull? recognizeFaceRepo, OrNull? pref, OrNull? sqliteDb, OrNull? touchManager, @@ -196,6 +213,9 @@ class DiContainer { faceRecognitionPersonRepo: faceRecognitionPersonRepo == null ? _faceRecognitionPersonRepo : faceRecognitionPersonRepo.obj, + recognizeFaceRepo: recognizeFaceRepo == null + ? _recognizeFaceRepo + : recognizeFaceRepo.obj, pref: pref == null ? _pref : pref.obj, sqliteDb: sqliteDb == null ? _sqliteDb : sqliteDb.obj, touchManager: touchManager == null ? _touchManager : touchManager.obj, @@ -229,6 +249,9 @@ class DiContainer { _faceRecognitionPersonRepoRemote!; FaceRecognitionPersonRepo get faceRecognitionPersonRepoLocal => _faceRecognitionPersonRepoLocal!; + RecognizeFaceRepo get recognizeFaceRepo => _recognizeFaceRepo!; + RecognizeFaceRepo get recognizeFaceRepoRemote => _recognizeFaceRepoRemote!; + RecognizeFaceRepo get recognizeFaceRepoLocal => _recognizeFaceRepoLocal!; sql.SqliteDb get sqliteDb => _sqliteDb!; Pref get pref => _pref!; @@ -354,6 +377,21 @@ class DiContainer { _faceRecognitionPersonRepoLocal = v; } + set recognizeFaceRepo(RecognizeFaceRepo v) { + assert(_recognizeFaceRepo == null); + _recognizeFaceRepo = v; + } + + set recognizeFaceRepoRemote(RecognizeFaceRepo v) { + assert(_recognizeFaceRepoRemote == null); + _recognizeFaceRepoRemote = v; + } + + set recognizeFaceRepoLocal(RecognizeFaceRepo v) { + assert(_recognizeFaceRepoLocal == null); + _recognizeFaceRepoLocal = v; + } + set sqliteDb(sql.SqliteDb v) { assert(_sqliteDb == null); _sqliteDb = v; @@ -396,6 +434,9 @@ class DiContainer { FaceRecognitionPersonRepo? _faceRecognitionPersonRepo; FaceRecognitionPersonRepo? _faceRecognitionPersonRepoRemote; FaceRecognitionPersonRepo? _faceRecognitionPersonRepoLocal; + RecognizeFaceRepo? _recognizeFaceRepo; + RecognizeFaceRepo? _recognizeFaceRepoRemote; + RecognizeFaceRepo? _recognizeFaceRepoLocal; sql.SqliteDb? _sqliteDb; Pref? _pref; @@ -413,6 +454,7 @@ extension DiContainerExtension on DiContainer { tagRepo: OrNull(_tagRepoRemote), ncAlbumRepo: OrNull(_ncAlbumRepoRemote), faceRecognitionPersonRepo: OrNull(_faceRecognitionPersonRepoRemote), + recognizeFaceRepo: OrNull(_recognizeFaceRepoRemote), ); /// Uses local repo if available @@ -425,6 +467,7 @@ extension DiContainerExtension on DiContainer { tagRepo: OrNull(_tagRepoLocal), ncAlbumRepo: OrNull(_ncAlbumRepoLocal), faceRecognitionPersonRepo: OrNull(_faceRecognitionPersonRepoLocal), + recognizeFaceRepo: OrNull(_recognizeFaceRepoLocal), ); DiContainer withLocalAlbumRepo() => diff --git a/app/lib/entity/person.dart b/app/lib/entity/person.dart index 8bcb8b5d..1da1724c 100644 --- a/app/lib/entity/person.dart +++ b/app/lib/entity/person.dart @@ -1,5 +1,6 @@ import 'package:copy_with/copy_with.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; import 'package:to_string/to_string.dart'; part 'person.g.dart'; @@ -40,6 +41,10 @@ class Person with EquatableMixin { isKeepAspectRatio: isKeepAspectRatio, ); + /// See [PersonContentProvider.getCoverTransform] + Matrix4? getCoverTransform(int viewportSize, int width, int height) => + contentProvider.getCoverTransform(viewportSize, width, height); + @override List get props => [ name, @@ -73,4 +78,9 @@ abstract class PersonContentProvider with EquatableMixin { int height, { bool? isKeepAspectRatio, }); + + /// Return the transformation matrix to focus the face + /// + /// Only viewport in square is supported + Matrix4? getCoverTransform(int viewportSize, int width, int height); } diff --git a/app/lib/entity/person/adapter.dart b/app/lib/entity/person/adapter.dart index 9ae08c56..72221d8e 100644 --- a/app/lib/entity/person/adapter.dart +++ b/app/lib/entity/person/adapter.dart @@ -2,7 +2,9 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/person/adapter/face_recognition.dart'; +import 'package:nc_photos/entity/person/adapter/recognize.dart'; import 'package:nc_photos/entity/person/content_provider/face_recognition.dart'; +import 'package:nc_photos/entity/person/content_provider/recognize.dart'; import 'package:nc_photos/entity/person_face.dart'; abstract class PersonAdapter { @@ -12,6 +14,8 @@ abstract class PersonAdapter { switch (person.contentProvider.runtimeType) { case PersonFaceRecognitionProvider: return PersonFaceRecognitionAdapter(c, account, person); + case PersonRecognizeProvider: + return PersonRecognizeAdapter(c, account, person); default: throw UnsupportedError( "Unknown type: ${person.contentProvider.runtimeType}"); diff --git a/app/lib/entity/person/adapter/recognize.dart b/app/lib/entity/person/adapter/recognize.dart new file mode 100644 index 00000000..da198790 --- /dev/null +++ b/app/lib/entity/person/adapter/recognize.dart @@ -0,0 +1,47 @@ +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/person.dart'; +import 'package:nc_photos/entity/person/adapter.dart'; +import 'package:nc_photos/entity/person/content_provider/recognize.dart'; +import 'package:nc_photos/entity/person_face.dart'; +import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/use_case/find_file_descriptor.dart'; +import 'package:nc_photos/use_case/recognize_face/list_recognize_face_item.dart'; +import 'package:np_codegen/np_codegen.dart'; + +part 'recognize.g.dart'; + +@npLog +class PersonRecognizeAdapter implements PersonAdapter { + PersonRecognizeAdapter(this._c, this.account, this.person) + : _provider = person.contentProvider as PersonRecognizeProvider; + + @override + Stream> listFace() { + return ListRecognizeFaceItem(_c)(account, _provider.face) + .asyncMap((faces) async { + final found = await FindFileDescriptor(_c)( + account, + faces.map((e) => e.fileId).toList(), + onFileNotFound: (fileId) { + _log.warning("[listFace] File not found: $fileId"); + }, + ); + return faces + .map((i) { + final f = found.firstWhereOrNull((e) => e.fdId == i.fileId); + return f?.run(BasicPersonFace.new); + }) + .whereNotNull() + .toList(); + }); + } + + final DiContainer _c; + final Account account; + final Person person; + + final PersonRecognizeProvider _provider; +} diff --git a/app/lib/entity/person/adapter/recognize.g.dart b/app/lib/entity/person/adapter/recognize.g.dart new file mode 100644 index 00000000..ce54ee20 --- /dev/null +++ b/app/lib/entity/person/adapter/recognize.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recognize.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$PersonRecognizeAdapterNpLog on PersonRecognizeAdapter { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("entity.person.adapter.recognize.PersonRecognizeAdapter"); +} diff --git a/app/lib/entity/person/builder.dart b/app/lib/entity/person/builder.dart index 8217a908..5b64428e 100644 --- a/app/lib/entity/person/builder.dart +++ b/app/lib/entity/person/builder.dart @@ -2,6 +2,9 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/face_recognition_person.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/person/content_provider/face_recognition.dart'; +import 'package:nc_photos/entity/person/content_provider/recognize.dart'; +import 'package:nc_photos/entity/recognize_face.dart'; +import 'package:nc_photos/entity/recognize_face_item.dart'; class PersonBuilder { static Person byFaceRecognitionPerson( @@ -14,4 +17,16 @@ class PersonBuilder { ), ); } + + static Person byRecognizeFace( + Account account, RecognizeFace face, List? items) { + return Person( + name: face.isNamed ? face.label : "", + contentProvider: PersonRecognizeProvider( + account: account, + face: face, + items: items, + ), + ); + } } diff --git a/app/lib/entity/person/content_provider/face_recognition.dart b/app/lib/entity/person/content_provider/face_recognition.dart index 5de7edd8..e7de2bc0 100644 --- a/app/lib/entity/person/content_provider/face_recognition.dart +++ b/app/lib/entity/person/content_provider/face_recognition.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/entity/face_recognition_person.dart'; @@ -43,6 +44,9 @@ class PersonFaceRecognitionProvider ); } + @override + Matrix4? getCoverTransform(int viewportSize, int width, int height) => null; + @override List get props => [account, person]; diff --git a/app/lib/entity/person/content_provider/recognize.dart b/app/lib/entity/person/content_provider/recognize.dart new file mode 100644 index 00000000..043bb16f --- /dev/null +++ b/app/lib/entity/person/content_provider/recognize.dart @@ -0,0 +1,111 @@ +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/entity/person.dart'; +import 'package:nc_photos/entity/recognize_face.dart'; +import 'package:nc_photos/entity/recognize_face_item.dart'; +import 'package:nc_photos/object_extension.dart'; +import 'package:to_string/to_string.dart'; + +part 'recognize.g.dart'; + +@toString +class PersonRecognizeProvider + with EquatableMixin + implements PersonContentProvider { + PersonRecognizeProvider({ + required this.account, + required this.face, + List? items, + }) : items = items + ?.sorted((a, b) => b.fileId.compareTo(a.fileId)) + .reversed + .toList(); + + @override + String toString() => _$toString(); + + @override + String get fourCc => "RCNZ"; + + @override + String get id => face.label; + + @override + int? get count => items?.length; + + @override + String? getCoverUrl( + int width, + int height, { + bool? isKeepAspectRatio, + }) => + items?.firstOrNull?.run((i) => api_util.getFilePreviewUrl( + account, + i.toFile(), + width: width, + height: height, + isKeepAspectRatio: isKeepAspectRatio ?? false, + )); + + @override + Matrix4? getCoverTransform(int viewportSize, int imgW, int imgH) { + final detection = items?.firstOrNull?.faceDetections + ?.firstWhereOrNull((e) => e["title"] == face.label); + if (detection == null) { + return null; + } + final faceXNorm = (detection["x"] as Object?).as(); + final faceYNorm = (detection["y"] as Object?).as(); + final faceHNorm = (detection["height"] as Object?).as(); + final faceWNorm = (detection["width"] as Object?).as(); + if (faceXNorm == null || + faceYNorm == null || + faceHNorm == null || + faceWNorm == null) { + return null; + } + + // move image to the face + double mx = imgW * -faceXNorm; + double my = imgH * -faceYNorm; + // add offset in case image is not a square + if (imgW > imgH) { + mx += (imgW - imgH) / 2; + } else if (imgH > imgW) { + my += (imgH - imgW) / 2; + } + + // scale image to focus on the face + final faceW = imgW * faceWNorm; + final faceH = imgH * faceHNorm; + double ms; + if (faceW > faceH) { + ms = viewportSize / faceW; + } else { + ms = viewportSize / faceH; + } + // slightly scale down to include pixels around the face + ms *= .75; + + // center the scaled image + final resultFaceW = faceW * ms; + final resultFaceH = faceH * ms; + final cx = (viewportSize - resultFaceW) / 2; + final cy = (viewportSize - resultFaceH) / 2; + + return Matrix4.identity() + ..translate(cx, cy) + ..scale(ms) + ..translate(mx, my); + } + + @override + List get props => [account, face, items]; + + final Account account; + final RecognizeFace face; + final List? items; +} diff --git a/app/lib/entity/person/content_provider/recognize.g.dart b/app/lib/entity/person/content_provider/recognize.g.dart new file mode 100644 index 00000000..1766f58c --- /dev/null +++ b/app/lib/entity/person/content_provider/recognize.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recognize.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$PersonRecognizeProviderToString on PersonRecognizeProvider { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "PersonRecognizeProvider {account: $account, face: $face, items: ${items == null ? null : "[length: ${items!.length}]"}}"; + } +} diff --git a/app/lib/entity/recognize_face.dart b/app/lib/entity/recognize_face.dart new file mode 100644 index 00000000..fcf92e19 --- /dev/null +++ b/app/lib/entity/recognize_face.dart @@ -0,0 +1,26 @@ +import 'package:equatable/equatable.dart'; +import 'package:to_string/to_string.dart'; + +part 'recognize_face.g.dart'; + +/// A person's face recognized by the Recognize app +/// +/// Beware that the terminology used in Recognize is different to +/// FaceRecognition, which is also followed by this app. A face in Recognize is +/// a person in FaceRecognition and this app +@toString +class RecognizeFace with EquatableMixin { + const RecognizeFace({ + required this.label, + }); + + bool get isNamed => int.tryParse(label) == null; + + @override + String toString() => _$toString(); + + @override + List get props => [label]; + + final String label; +} diff --git a/app/lib/entity/recognize_face.g.dart b/app/lib/entity/recognize_face.g.dart new file mode 100644 index 00000000..e50bca05 --- /dev/null +++ b/app/lib/entity/recognize_face.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recognize_face.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$RecognizeFaceToString on RecognizeFace { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "RecognizeFace {label: $label}"; + } +} diff --git a/app/lib/entity/recognize_face/data_source.dart b/app/lib/entity/recognize_face/data_source.dart new file mode 100644 index 00000000..1960d55e --- /dev/null +++ b/app/lib/entity/recognize_face/data_source.dart @@ -0,0 +1,212 @@ +import 'package:collection/collection.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/recognize_face.dart'; +import 'package:nc_photos/entity/recognize_face/repo.dart'; +import 'package:nc_photos/entity/recognize_face_item.dart'; +import 'package:nc_photos/entity/sqlite/database.dart' as sql; +import 'package:nc_photos/entity/sqlite/table.dart'; +import 'package:nc_photos/entity/sqlite/type_converter.dart'; +import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/map_extension.dart'; +import 'package:nc_photos/np_api_util.dart'; +import 'package:np_api/np_api.dart' as api; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; + +part 'data_source.g.dart'; + +@npLog +class RecognizeFaceRemoteDataSource implements RecognizeFaceDataSource { + const RecognizeFaceRemoteDataSource(); + + @override + Future> getFaces(Account account) async { + _log.info("[getFaces] account: ${account.userId}"); + final response = await ApiUtil.fromAccount(account) + .recognize(account.userId.raw) + .faces() + .propfind(); + if (!response.isGood) { + _log.severe("[getFaces] Failed requesting server: $response"); + throw ApiException( + response: response, + message: "Server responed with an error: HTTP ${response.statusCode}", + ); + } + + final apiFaces = await api.RecognizeFaceParser().parse(response.body); + return apiFaces + .map(ApiRecognizeFaceConverter.fromApi) + .where((e) => e.label.isNotEmpty) + .toList(); + } + + @override + Future> getItems( + Account account, RecognizeFace face) async { + _log.info("[getItems] account: ${account.userId}, face: ${face.label}"); + final response = await ApiUtil.fromAccount(account) + .recognize(account.userId.raw) + .face(face.label) + .propfind( + getcontentlength: 1, + getcontenttype: 1, + getetag: 1, + getlastmodified: 1, + faceDetections: 1, + fileMetadataSize: 1, + hasPreview: 1, + realpath: 1, + favorite: 1, + 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 apiItems = await api.RecognizeFaceItemParser().parse(response.body); + return apiItems + .where((f) => f.fileId != null) + .map(ApiRecognizeFaceItemConverter.fromApi) + .toList(); + } + + @override + Future>> getMultiFaceItems( + Account account, + List faces, { + ErrorWithValueHandler? onError, + }) async { + final results = await Future.wait(faces.map((f) async { + try { + return MapEntry(f, await getItems(account, f)); + } catch (e, stackTrace) { + _log.severe("[getMultiFaceItems] Failed while querying face: $f", e, + stackTrace); + onError?.call(f, e, stackTrace); + return null; + } + })); + return results.whereNotNull().toMap(); + } + + @override + Future> getMultiFaceLastItems( + Account account, + List faces, { + ErrorWithValueHandler? onError, + }) async { + final results = await getMultiFaceItems(account, faces, onError: onError); + return results + .map((key, value) => MapEntry(key, maxBy(value, (e) => e.fileId)!)); + } +} + +@npLog +class RecognizeFaceSqliteDbDataSource implements RecognizeFaceDataSource { + const RecognizeFaceSqliteDbDataSource(this.sqliteDb); + + @override + Future> getFaces(Account account) async { + _log.info("[getFaces] $account"); + final dbFaces = await sqliteDb.use((db) async { + return await db.allRecognizeFaces( + account: sql.ByAccount.app(account), + ); + }); + return dbFaces + .map((f) { + try { + return SqliteRecognizeFaceConverter.fromSql(f); + } catch (e, stackTrace) { + _log.severe( + "[getFaces] Failed while converting DB entry", e, stackTrace); + return null; + } + }) + .whereNotNull() + .toList(); + } + + @override + Future> getItems( + Account account, RecognizeFace face) async { + _log.info("[getItems] $face"); + final results = await getMultiFaceItems(account, [face]); + return results[face]!; + } + + @override + Future>> getMultiFaceItems( + Account account, + List faces, { + ErrorWithValueHandler? onError, + List? orderBy, + int? limit, + }) async { + _log.info("[getMultiFaceItems] ${faces.toReadableString()}"); + final dbItems = await sqliteDb.use((db) async { + final results = await Future.wait(faces.map((f) async { + try { + return MapEntry( + f, + await db.recognizeFaceItemsByParentLabel( + account: sql.ByAccount.app(account), + label: f.label, + orderBy: orderBy?.toOrderingItem(db).toList(), + limit: limit, + ), + ); + } catch (e, stackTrace) { + onError?.call(f, e, stackTrace); + return null; + } + })); + return results.whereNotNull().toMap(); + }); + return dbItems.entries + .map((entry) { + final face = entry.key; + try { + return MapEntry( + face, + entry.value + .map((i) => SqliteRecognizeFaceItemConverter.fromSql( + account.userId.raw, face.label, i)) + .toList(), + ); + } catch (e, stackTrace) { + onError?.call(face, e, stackTrace); + return null; + } + }) + .whereNotNull() + .toMap(); + } + + @override + Future> getMultiFaceLastItems( + Account account, + List faces, { + ErrorWithValueHandler? onError, + }) async { + final results = await getMultiFaceItems( + account, + faces, + onError: onError, + orderBy: [RecognizeFaceItemSort.fileIdDesc], + limit: 1, + ); + return (results..removeWhere((key, value) => value.isEmpty)) + .map((key, value) => MapEntry(key, value.first)); + } + + final sql.SqliteDb sqliteDb; +} diff --git a/app/lib/entity/recognize_face/data_source.g.dart b/app/lib/entity/recognize_face/data_source.g.dart new file mode 100644 index 00000000..e67d42e9 --- /dev/null +++ b/app/lib/entity/recognize_face/data_source.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_source.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$RecognizeFaceRemoteDataSourceNpLog + on RecognizeFaceRemoteDataSource { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("entity.recognize_face.data_source.RecognizeFaceRemoteDataSource"); +} + +extension _$RecognizeFaceSqliteDbDataSourceNpLog + on RecognizeFaceSqliteDbDataSource { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger( + "entity.recognize_face.data_source.RecognizeFaceSqliteDbDataSource"); +} diff --git a/app/lib/entity/recognize_face/repo.dart b/app/lib/entity/recognize_face/repo.dart new file mode 100644 index 00000000..83f6df61 --- /dev/null +++ b/app/lib/entity/recognize_face/repo.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/entity/recognize_face.dart'; +import 'package:nc_photos/entity/recognize_face_item.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/type.dart'; + +part 'repo.g.dart'; + +abstract class RecognizeFaceRepo { + /// Query all [RecognizeFace]s belonging to [account] + Stream> getFaces(Account account); + + /// Query all items belonging to [face] + Stream> getItems(Account account, RecognizeFace face); + + /// Query all items belonging to each face + Stream>> getMultiFaceItems( + Account account, + List faces, { + ErrorWithValueHandler? onError, + }); +} + +/// A repo that simply relay the call to the backed [NcAlbumDataSource] +@npLog +class BasicRecognizeFaceRepo implements RecognizeFaceRepo { + const BasicRecognizeFaceRepo(this.dataSrc); + + @override + Stream> getFaces(Account account) async* { + yield await dataSrc.getFaces(account); + } + + @override + Stream> getItems( + Account account, RecognizeFace face) async* { + yield await dataSrc.getItems(account, face); + } + + @override + Stream>> getMultiFaceItems( + Account account, + List faces, { + ErrorWithValueHandler? onError, + }) async* { + yield await dataSrc.getMultiFaceItems(account, faces, onError: onError); + } + + final RecognizeFaceDataSource dataSrc; +} + +abstract class RecognizeFaceDataSource { + /// Query all [RecognizeFace]s belonging to [account] + Future> getFaces(Account account); + + /// Query all items belonging to [face] + Future> getItems(Account account, RecognizeFace face); + + /// Query all items belonging to each face + Future>> getMultiFaceItems( + Account account, + List faces, { + ErrorWithValueHandler? onError, + }); + + /// Query the last items belonging to each face + Future> getMultiFaceLastItems( + Account account, + List faces, { + ErrorWithValueHandler? onError, + }); +} diff --git a/app/lib/entity/recognize_face/repo.g.dart b/app/lib/entity/recognize_face/repo.g.dart new file mode 100644 index 00000000..98078db5 --- /dev/null +++ b/app/lib/entity/recognize_face/repo.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'repo.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$BasicRecognizeFaceRepoNpLog on BasicRecognizeFaceRepo { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("entity.recognize_face.repo.BasicRecognizeFaceRepo"); +} diff --git a/app/lib/entity/recognize_face_item.dart b/app/lib/entity/recognize_face_item.dart new file mode 100644 index 00000000..973b5cf1 --- /dev/null +++ b/app/lib/entity/recognize_face_item.dart @@ -0,0 +1,113 @@ +import 'package:equatable/equatable.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:np_api/np_api.dart' as api; +import 'package:np_common/string_extension.dart'; +import 'package:to_string/to_string.dart'; + +part 'recognize_face_item.g.dart'; + +@ToString(ignoreNull: true) +class RecognizeFaceItem with EquatableMixin { + const RecognizeFaceItem({ + required this.path, + required this.fileId, + this.contentLength, + this.contentType, + this.etag, + this.lastModified, + this.hasPreview, + this.realPath, + this.isFavorite, + this.fileMetadataWidth, + this.fileMetadataHeight, + this.faceDetections, + }); + + @override + String toString() => _$toString(); + + @override + List get props => [ + path, + fileId, + contentLength, + contentType, + etag, + lastModified, + hasPreview, + realPath, + isFavorite, + fileMetadataWidth, + fileMetadataHeight, + faceDetections, + ]; + + final String path; + final int fileId; + final int? contentLength; + final String? contentType; + final String? etag; + final DateTime? lastModified; + final bool? hasPreview; + final String? realPath; + final bool? isFavorite; + final int? fileMetadataWidth; + final int? fileMetadataHeight; + final List>? faceDetections; +} + +extension RecognizeFaceItemExtension on RecognizeFaceItem { + /// Return the path of this item with the DAV part stripped + /// + /// WebDAV file path: remote.php/dav/recognize/{userId}/faces/{face}/{strippedPath}. + /// If this path points to the user's root album path, return "." + String get strippedPath { + if (!path.startsWith("${api.ApiRecognize.path}/")) { + throw ArgumentError("Unsupported path: $path"); + } + var begin = "${api.ApiRecognize.path}/".length; + begin = path.indexOf("/", begin); + if (begin == -1) { + throw ArgumentError("Unsupported path: $path"); + } + // /faces/{face}/{strippedPath} + if (path.slice(begin, begin + 6) != "/faces") { + throw ArgumentError("Unsupported path: $path"); + } + begin += 7; + // {face}/{strippedPath} + begin = path.indexOf("/", begin); + if (begin == -1) { + return "."; + } + return path.slice(begin + 1); + } + + bool compareIdentity(RecognizeFaceItem other) => fileId == other.fileId; + + int get identityHashCode => fileId.hashCode; + + static int identityComparator(RecognizeFaceItem a, RecognizeFaceItem b) => + a.fileId.compareTo(b.fileId); + + File toFile() { + Metadata? metadata; + if (fileMetadataWidth != null && fileMetadataHeight != null) { + metadata = Metadata( + imageWidth: fileMetadataWidth, + imageHeight: fileMetadataHeight, + ); + } + return File( + path: realPath ?? path, + fileId: fileId, + contentLength: contentLength, + contentType: contentType, + etag: etag, + lastModified: lastModified, + hasPreview: hasPreview, + isFavorite: isFavorite, + metadata: metadata, + ); + } +} diff --git a/app/lib/entity/recognize_face_item.g.dart b/app/lib/entity/recognize_face_item.g.dart new file mode 100644 index 00000000..2f230548 --- /dev/null +++ b/app/lib/entity/recognize_face_item.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recognize_face_item.dart'; + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$RecognizeFaceItemToString on RecognizeFaceItem { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "RecognizeFaceItem {path: $path, fileId: $fileId, ${contentLength == null ? "" : "contentLength: $contentLength, "}${contentType == null ? "" : "contentType: $contentType, "}${etag == null ? "" : "etag: $etag, "}${lastModified == null ? "" : "lastModified: $lastModified, "}${hasPreview == null ? "" : "hasPreview: $hasPreview, "}${realPath == null ? "" : "realPath: $realPath, "}${isFavorite == null ? "" : "isFavorite: $isFavorite, "}${fileMetadataWidth == null ? "" : "fileMetadataWidth: $fileMetadataWidth, "}${fileMetadataHeight == null ? "" : "fileMetadataHeight: $fileMetadataHeight, "}${faceDetections == null ? "" : "faceDetections: [length: ${faceDetections!.length}]"}}"; + } +} diff --git a/app/lib/entity/sqlite/database.dart b/app/lib/entity/sqlite/database.dart index c71cdb95..692a3161 100644 --- a/app/lib/entity/sqlite/database.dart +++ b/app/lib/entity/sqlite/database.dart @@ -39,6 +39,8 @@ part 'database_extension.dart'; FaceRecognitionPersons, NcAlbums, NcAlbumItems, + RecognizeFaces, + RecognizeFaceItems, ], ) class SqliteDb extends _$SqliteDb { @@ -104,6 +106,8 @@ class SqliteDb extends _$SqliteDb { if (from >= 2) { await m.renameTable(faceRecognitionPersons, "persons"); } + await m.createTable(recognizeFaces); + await m.createTable(recognizeFaceItems); } }); } catch (e, stackTrace) { diff --git a/app/lib/entity/sqlite/database.g.dart b/app/lib/entity/sqlite/database.g.dart index b23a0444..f284fa0a 100644 --- a/app/lib/entity/sqlite/database.g.dart +++ b/app/lib/entity/sqlite/database.g.dart @@ -5288,6 +5288,920 @@ class NcAlbumItemsCompanion extends UpdateCompanion { } } +class $RecognizeFacesTable extends RecognizeFaces + with TableInfo<$RecognizeFacesTable, RecognizeFace> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $RecognizeFacesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _rowIdMeta = const VerificationMeta('rowId'); + @override + late final GeneratedColumn rowId = GeneratedColumn( + 'row_id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _accountMeta = + const VerificationMeta('account'); + @override + late final GeneratedColumn account = GeneratedColumn( + 'account', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES accounts (row_id) ON DELETE CASCADE')); + static const VerificationMeta _labelMeta = const VerificationMeta('label'); + @override + late final GeneratedColumn label = GeneratedColumn( + 'label', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + @override + List get $columns => [rowId, account, label]; + @override + String get aliasedName => _alias ?? 'recognize_faces'; + @override + String get actualTableName => 'recognize_faces'; + @override + VerificationContext validateIntegrity(Insertable 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('label')) { + context.handle( + _labelMeta, label.isAcceptableOrUnknown(data['label']!, _labelMeta)); + } else if (isInserting) { + context.missing(_labelMeta); + } + return context; + } + + @override + Set get $primaryKey => {rowId}; + @override + List> get uniqueKeys => [ + {account, label}, + ]; + @override + RecognizeFace map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RecognizeFace( + rowId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}row_id'])!, + account: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}account'])!, + label: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}label'])!, + ); + } + + @override + $RecognizeFacesTable createAlias(String alias) { + return $RecognizeFacesTable(attachedDatabase, alias); + } +} + +class RecognizeFace extends DataClass implements Insertable { + final int rowId; + final int account; + final String label; + const RecognizeFace( + {required this.rowId, required this.account, required this.label}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['row_id'] = Variable(rowId); + map['account'] = Variable(account); + map['label'] = Variable(label); + return map; + } + + RecognizeFacesCompanion toCompanion(bool nullToAbsent) { + return RecognizeFacesCompanion( + rowId: Value(rowId), + account: Value(account), + label: Value(label), + ); + } + + factory RecognizeFace.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RecognizeFace( + rowId: serializer.fromJson(json['rowId']), + account: serializer.fromJson(json['account']), + label: serializer.fromJson(json['label']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'rowId': serializer.toJson(rowId), + 'account': serializer.toJson(account), + 'label': serializer.toJson(label), + }; + } + + RecognizeFace copyWith({int? rowId, int? account, String? label}) => + RecognizeFace( + rowId: rowId ?? this.rowId, + account: account ?? this.account, + label: label ?? this.label, + ); + @override + String toString() { + return (StringBuffer('RecognizeFace(') + ..write('rowId: $rowId, ') + ..write('account: $account, ') + ..write('label: $label') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(rowId, account, label); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RecognizeFace && + other.rowId == this.rowId && + other.account == this.account && + other.label == this.label); +} + +class RecognizeFacesCompanion extends UpdateCompanion { + final Value rowId; + final Value account; + final Value label; + const RecognizeFacesCompanion({ + this.rowId = const Value.absent(), + this.account = const Value.absent(), + this.label = const Value.absent(), + }); + RecognizeFacesCompanion.insert({ + this.rowId = const Value.absent(), + required int account, + required String label, + }) : account = Value(account), + label = Value(label); + static Insertable custom({ + Expression? rowId, + Expression? account, + Expression? label, + }) { + return RawValuesInsertable({ + if (rowId != null) 'row_id': rowId, + if (account != null) 'account': account, + if (label != null) 'label': label, + }); + } + + RecognizeFacesCompanion copyWith( + {Value? rowId, Value? account, Value? label}) { + return RecognizeFacesCompanion( + rowId: rowId ?? this.rowId, + account: account ?? this.account, + label: label ?? this.label, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (rowId.present) { + map['row_id'] = Variable(rowId.value); + } + if (account.present) { + map['account'] = Variable(account.value); + } + if (label.present) { + map['label'] = Variable(label.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RecognizeFacesCompanion(') + ..write('rowId: $rowId, ') + ..write('account: $account, ') + ..write('label: $label') + ..write(')')) + .toString(); + } +} + +class $RecognizeFaceItemsTable extends RecognizeFaceItems + with TableInfo<$RecognizeFaceItemsTable, RecognizeFaceItem> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $RecognizeFaceItemsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _rowIdMeta = const VerificationMeta('rowId'); + @override + late final GeneratedColumn rowId = GeneratedColumn( + 'row_id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _parentMeta = const VerificationMeta('parent'); + @override + late final GeneratedColumn parent = GeneratedColumn( + 'parent', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES recognize_faces (row_id) ON DELETE CASCADE')); + static const VerificationMeta _relativePathMeta = + const VerificationMeta('relativePath'); + @override + late final GeneratedColumn relativePath = GeneratedColumn( + 'relative_path', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _fileIdMeta = const VerificationMeta('fileId'); + @override + late final GeneratedColumn fileId = GeneratedColumn( + 'file_id', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const VerificationMeta _contentLengthMeta = + const VerificationMeta('contentLength'); + @override + late final GeneratedColumn contentLength = GeneratedColumn( + 'content_length', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _contentTypeMeta = + const VerificationMeta('contentType'); + @override + late final GeneratedColumn contentType = GeneratedColumn( + 'content_type', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _etagMeta = const VerificationMeta('etag'); + @override + late final GeneratedColumn etag = GeneratedColumn( + 'etag', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _lastModifiedMeta = + const VerificationMeta('lastModified'); + @override + late final GeneratedColumnWithTypeConverter + lastModified = GeneratedColumn( + 'last_modified', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false) + .withConverter( + $RecognizeFaceItemsTable.$converterlastModifiedn); + static const VerificationMeta _hasPreviewMeta = + const VerificationMeta('hasPreview'); + @override + late final GeneratedColumn hasPreview = + GeneratedColumn('has_preview', aliasedName, true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintsDependsOnDialect({ + SqlDialect.sqlite: 'CHECK ("has_preview" IN (0, 1))', + SqlDialect.mysql: '', + SqlDialect.postgres: '', + })); + static const VerificationMeta _realPathMeta = + const VerificationMeta('realPath'); + @override + late final GeneratedColumn realPath = GeneratedColumn( + 'real_path', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _isFavoriteMeta = + const VerificationMeta('isFavorite'); + @override + late final GeneratedColumn isFavorite = + GeneratedColumn('is_favorite', aliasedName, true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintsDependsOnDialect({ + SqlDialect.sqlite: 'CHECK ("is_favorite" IN (0, 1))', + SqlDialect.mysql: '', + SqlDialect.postgres: '', + })); + static const VerificationMeta _fileMetadataWidthMeta = + const VerificationMeta('fileMetadataWidth'); + @override + late final GeneratedColumn fileMetadataWidth = GeneratedColumn( + 'file_metadata_width', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _fileMetadataHeightMeta = + const VerificationMeta('fileMetadataHeight'); + @override + late final GeneratedColumn fileMetadataHeight = GeneratedColumn( + 'file_metadata_height', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _faceDetectionsMeta = + const VerificationMeta('faceDetections'); + @override + late final GeneratedColumn faceDetections = GeneratedColumn( + 'face_detections', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => [ + rowId, + parent, + relativePath, + fileId, + contentLength, + contentType, + etag, + lastModified, + hasPreview, + realPath, + isFavorite, + fileMetadataWidth, + fileMetadataHeight, + faceDetections + ]; + @override + String get aliasedName => _alias ?? 'recognize_face_items'; + @override + String get actualTableName => 'recognize_face_items'; + @override + VerificationContext validateIntegrity(Insertable 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('relative_path')) { + context.handle( + _relativePathMeta, + relativePath.isAcceptableOrUnknown( + data['relative_path']!, _relativePathMeta)); + } else if (isInserting) { + context.missing(_relativePathMeta); + } + if (data.containsKey('file_id')) { + context.handle(_fileIdMeta, + fileId.isAcceptableOrUnknown(data['file_id']!, _fileIdMeta)); + } else if (isInserting) { + context.missing(_fileIdMeta); + } + if (data.containsKey('content_length')) { + context.handle( + _contentLengthMeta, + contentLength.isAcceptableOrUnknown( + data['content_length']!, _contentLengthMeta)); + } + if (data.containsKey('content_type')) { + context.handle( + _contentTypeMeta, + contentType.isAcceptableOrUnknown( + data['content_type']!, _contentTypeMeta)); + } + if (data.containsKey('etag')) { + context.handle( + _etagMeta, etag.isAcceptableOrUnknown(data['etag']!, _etagMeta)); + } + context.handle(_lastModifiedMeta, const VerificationResult.success()); + if (data.containsKey('has_preview')) { + context.handle( + _hasPreviewMeta, + hasPreview.isAcceptableOrUnknown( + data['has_preview']!, _hasPreviewMeta)); + } + if (data.containsKey('real_path')) { + context.handle(_realPathMeta, + realPath.isAcceptableOrUnknown(data['real_path']!, _realPathMeta)); + } + if (data.containsKey('is_favorite')) { + context.handle( + _isFavoriteMeta, + isFavorite.isAcceptableOrUnknown( + data['is_favorite']!, _isFavoriteMeta)); + } + if (data.containsKey('file_metadata_width')) { + context.handle( + _fileMetadataWidthMeta, + fileMetadataWidth.isAcceptableOrUnknown( + data['file_metadata_width']!, _fileMetadataWidthMeta)); + } + if (data.containsKey('file_metadata_height')) { + context.handle( + _fileMetadataHeightMeta, + fileMetadataHeight.isAcceptableOrUnknown( + data['file_metadata_height']!, _fileMetadataHeightMeta)); + } + if (data.containsKey('face_detections')) { + context.handle( + _faceDetectionsMeta, + faceDetections.isAcceptableOrUnknown( + data['face_detections']!, _faceDetectionsMeta)); + } + return context; + } + + @override + Set get $primaryKey => {rowId}; + @override + List> get uniqueKeys => [ + {parent, fileId}, + ]; + @override + RecognizeFaceItem map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RecognizeFaceItem( + rowId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}row_id'])!, + parent: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}parent'])!, + relativePath: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}relative_path'])!, + fileId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}file_id'])!, + contentLength: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}content_length']), + contentType: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}content_type']), + etag: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}etag']), + lastModified: $RecognizeFaceItemsTable.$converterlastModifiedn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}last_modified'])), + hasPreview: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}has_preview']), + realPath: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}real_path']), + isFavorite: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_favorite']), + fileMetadataWidth: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}file_metadata_width']), + fileMetadataHeight: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}file_metadata_height']), + faceDetections: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}face_detections']), + ); + } + + @override + $RecognizeFaceItemsTable createAlias(String alias) { + return $RecognizeFaceItemsTable(attachedDatabase, alias); + } + + static TypeConverter $converterlastModified = + const SqliteDateTimeConverter(); + static TypeConverter $converterlastModifiedn = + NullAwareTypeConverter.wrap($converterlastModified); +} + +class RecognizeFaceItem extends DataClass + implements Insertable { + final int rowId; + final int parent; + final String relativePath; + final int fileId; + final int? contentLength; + final String? contentType; + final String? etag; + final DateTime? lastModified; + final bool? hasPreview; + final String? realPath; + final bool? isFavorite; + final int? fileMetadataWidth; + final int? fileMetadataHeight; + final String? faceDetections; + const RecognizeFaceItem( + {required this.rowId, + required this.parent, + required this.relativePath, + required this.fileId, + this.contentLength, + this.contentType, + this.etag, + this.lastModified, + this.hasPreview, + this.realPath, + this.isFavorite, + this.fileMetadataWidth, + this.fileMetadataHeight, + this.faceDetections}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['row_id'] = Variable(rowId); + map['parent'] = Variable(parent); + map['relative_path'] = Variable(relativePath); + map['file_id'] = Variable(fileId); + if (!nullToAbsent || contentLength != null) { + map['content_length'] = Variable(contentLength); + } + if (!nullToAbsent || contentType != null) { + map['content_type'] = Variable(contentType); + } + if (!nullToAbsent || etag != null) { + map['etag'] = Variable(etag); + } + if (!nullToAbsent || lastModified != null) { + final converter = $RecognizeFaceItemsTable.$converterlastModifiedn; + map['last_modified'] = Variable(converter.toSql(lastModified)); + } + if (!nullToAbsent || hasPreview != null) { + map['has_preview'] = Variable(hasPreview); + } + if (!nullToAbsent || realPath != null) { + map['real_path'] = Variable(realPath); + } + if (!nullToAbsent || isFavorite != null) { + map['is_favorite'] = Variable(isFavorite); + } + if (!nullToAbsent || fileMetadataWidth != null) { + map['file_metadata_width'] = Variable(fileMetadataWidth); + } + if (!nullToAbsent || fileMetadataHeight != null) { + map['file_metadata_height'] = Variable(fileMetadataHeight); + } + if (!nullToAbsent || faceDetections != null) { + map['face_detections'] = Variable(faceDetections); + } + return map; + } + + RecognizeFaceItemsCompanion toCompanion(bool nullToAbsent) { + return RecognizeFaceItemsCompanion( + rowId: Value(rowId), + parent: Value(parent), + relativePath: Value(relativePath), + fileId: Value(fileId), + contentLength: contentLength == null && nullToAbsent + ? const Value.absent() + : Value(contentLength), + contentType: contentType == null && nullToAbsent + ? const Value.absent() + : Value(contentType), + etag: etag == null && nullToAbsent ? const Value.absent() : Value(etag), + lastModified: lastModified == null && nullToAbsent + ? const Value.absent() + : Value(lastModified), + hasPreview: hasPreview == null && nullToAbsent + ? const Value.absent() + : Value(hasPreview), + realPath: realPath == null && nullToAbsent + ? const Value.absent() + : Value(realPath), + isFavorite: isFavorite == null && nullToAbsent + ? const Value.absent() + : Value(isFavorite), + fileMetadataWidth: fileMetadataWidth == null && nullToAbsent + ? const Value.absent() + : Value(fileMetadataWidth), + fileMetadataHeight: fileMetadataHeight == null && nullToAbsent + ? const Value.absent() + : Value(fileMetadataHeight), + faceDetections: faceDetections == null && nullToAbsent + ? const Value.absent() + : Value(faceDetections), + ); + } + + factory RecognizeFaceItem.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RecognizeFaceItem( + rowId: serializer.fromJson(json['rowId']), + parent: serializer.fromJson(json['parent']), + relativePath: serializer.fromJson(json['relativePath']), + fileId: serializer.fromJson(json['fileId']), + contentLength: serializer.fromJson(json['contentLength']), + contentType: serializer.fromJson(json['contentType']), + etag: serializer.fromJson(json['etag']), + lastModified: serializer.fromJson(json['lastModified']), + hasPreview: serializer.fromJson(json['hasPreview']), + realPath: serializer.fromJson(json['realPath']), + isFavorite: serializer.fromJson(json['isFavorite']), + fileMetadataWidth: serializer.fromJson(json['fileMetadataWidth']), + fileMetadataHeight: serializer.fromJson(json['fileMetadataHeight']), + faceDetections: serializer.fromJson(json['faceDetections']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'rowId': serializer.toJson(rowId), + 'parent': serializer.toJson(parent), + 'relativePath': serializer.toJson(relativePath), + 'fileId': serializer.toJson(fileId), + 'contentLength': serializer.toJson(contentLength), + 'contentType': serializer.toJson(contentType), + 'etag': serializer.toJson(etag), + 'lastModified': serializer.toJson(lastModified), + 'hasPreview': serializer.toJson(hasPreview), + 'realPath': serializer.toJson(realPath), + 'isFavorite': serializer.toJson(isFavorite), + 'fileMetadataWidth': serializer.toJson(fileMetadataWidth), + 'fileMetadataHeight': serializer.toJson(fileMetadataHeight), + 'faceDetections': serializer.toJson(faceDetections), + }; + } + + RecognizeFaceItem copyWith( + {int? rowId, + int? parent, + String? relativePath, + int? fileId, + Value contentLength = const Value.absent(), + Value contentType = const Value.absent(), + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value hasPreview = const Value.absent(), + Value realPath = const Value.absent(), + Value isFavorite = const Value.absent(), + Value fileMetadataWidth = const Value.absent(), + Value fileMetadataHeight = const Value.absent(), + Value faceDetections = const Value.absent()}) => + RecognizeFaceItem( + rowId: rowId ?? this.rowId, + parent: parent ?? this.parent, + relativePath: relativePath ?? this.relativePath, + fileId: fileId ?? this.fileId, + contentLength: + contentLength.present ? contentLength.value : this.contentLength, + contentType: contentType.present ? contentType.value : this.contentType, + etag: etag.present ? etag.value : this.etag, + lastModified: + lastModified.present ? lastModified.value : this.lastModified, + hasPreview: hasPreview.present ? hasPreview.value : this.hasPreview, + realPath: realPath.present ? realPath.value : this.realPath, + isFavorite: isFavorite.present ? isFavorite.value : this.isFavorite, + fileMetadataWidth: fileMetadataWidth.present + ? fileMetadataWidth.value + : this.fileMetadataWidth, + fileMetadataHeight: fileMetadataHeight.present + ? fileMetadataHeight.value + : this.fileMetadataHeight, + faceDetections: + faceDetections.present ? faceDetections.value : this.faceDetections, + ); + @override + String toString() { + return (StringBuffer('RecognizeFaceItem(') + ..write('rowId: $rowId, ') + ..write('parent: $parent, ') + ..write('relativePath: $relativePath, ') + ..write('fileId: $fileId, ') + ..write('contentLength: $contentLength, ') + ..write('contentType: $contentType, ') + ..write('etag: $etag, ') + ..write('lastModified: $lastModified, ') + ..write('hasPreview: $hasPreview, ') + ..write('realPath: $realPath, ') + ..write('isFavorite: $isFavorite, ') + ..write('fileMetadataWidth: $fileMetadataWidth, ') + ..write('fileMetadataHeight: $fileMetadataHeight, ') + ..write('faceDetections: $faceDetections') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + rowId, + parent, + relativePath, + fileId, + contentLength, + contentType, + etag, + lastModified, + hasPreview, + realPath, + isFavorite, + fileMetadataWidth, + fileMetadataHeight, + faceDetections); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RecognizeFaceItem && + other.rowId == this.rowId && + other.parent == this.parent && + other.relativePath == this.relativePath && + other.fileId == this.fileId && + other.contentLength == this.contentLength && + other.contentType == this.contentType && + other.etag == this.etag && + other.lastModified == this.lastModified && + other.hasPreview == this.hasPreview && + other.realPath == this.realPath && + other.isFavorite == this.isFavorite && + other.fileMetadataWidth == this.fileMetadataWidth && + other.fileMetadataHeight == this.fileMetadataHeight && + other.faceDetections == this.faceDetections); +} + +class RecognizeFaceItemsCompanion extends UpdateCompanion { + final Value rowId; + final Value parent; + final Value relativePath; + final Value fileId; + final Value contentLength; + final Value contentType; + final Value etag; + final Value lastModified; + final Value hasPreview; + final Value realPath; + final Value isFavorite; + final Value fileMetadataWidth; + final Value fileMetadataHeight; + final Value faceDetections; + const RecognizeFaceItemsCompanion({ + this.rowId = const Value.absent(), + this.parent = const Value.absent(), + this.relativePath = const Value.absent(), + this.fileId = const Value.absent(), + this.contentLength = const Value.absent(), + this.contentType = const Value.absent(), + this.etag = const Value.absent(), + this.lastModified = const Value.absent(), + this.hasPreview = const Value.absent(), + this.realPath = const Value.absent(), + this.isFavorite = const Value.absent(), + this.fileMetadataWidth = const Value.absent(), + this.fileMetadataHeight = const Value.absent(), + this.faceDetections = const Value.absent(), + }); + RecognizeFaceItemsCompanion.insert({ + this.rowId = const Value.absent(), + required int parent, + required String relativePath, + required int fileId, + this.contentLength = const Value.absent(), + this.contentType = const Value.absent(), + this.etag = const Value.absent(), + this.lastModified = const Value.absent(), + this.hasPreview = const Value.absent(), + this.realPath = const Value.absent(), + this.isFavorite = const Value.absent(), + this.fileMetadataWidth = const Value.absent(), + this.fileMetadataHeight = const Value.absent(), + this.faceDetections = const Value.absent(), + }) : parent = Value(parent), + relativePath = Value(relativePath), + fileId = Value(fileId); + static Insertable custom({ + Expression? rowId, + Expression? parent, + Expression? relativePath, + Expression? fileId, + Expression? contentLength, + Expression? contentType, + Expression? etag, + Expression? lastModified, + Expression? hasPreview, + Expression? realPath, + Expression? isFavorite, + Expression? fileMetadataWidth, + Expression? fileMetadataHeight, + Expression? faceDetections, + }) { + return RawValuesInsertable({ + if (rowId != null) 'row_id': rowId, + if (parent != null) 'parent': parent, + if (relativePath != null) 'relative_path': relativePath, + if (fileId != null) 'file_id': fileId, + if (contentLength != null) 'content_length': contentLength, + if (contentType != null) 'content_type': contentType, + if (etag != null) 'etag': etag, + if (lastModified != null) 'last_modified': lastModified, + if (hasPreview != null) 'has_preview': hasPreview, + if (realPath != null) 'real_path': realPath, + if (isFavorite != null) 'is_favorite': isFavorite, + if (fileMetadataWidth != null) 'file_metadata_width': fileMetadataWidth, + if (fileMetadataHeight != null) + 'file_metadata_height': fileMetadataHeight, + if (faceDetections != null) 'face_detections': faceDetections, + }); + } + + RecognizeFaceItemsCompanion copyWith( + {Value? rowId, + Value? parent, + Value? relativePath, + Value? fileId, + Value? contentLength, + Value? contentType, + Value? etag, + Value? lastModified, + Value? hasPreview, + Value? realPath, + Value? isFavorite, + Value? fileMetadataWidth, + Value? fileMetadataHeight, + Value? faceDetections}) { + return RecognizeFaceItemsCompanion( + rowId: rowId ?? this.rowId, + parent: parent ?? this.parent, + relativePath: relativePath ?? this.relativePath, + fileId: fileId ?? this.fileId, + contentLength: contentLength ?? this.contentLength, + contentType: contentType ?? this.contentType, + etag: etag ?? this.etag, + lastModified: lastModified ?? this.lastModified, + hasPreview: hasPreview ?? this.hasPreview, + realPath: realPath ?? this.realPath, + isFavorite: isFavorite ?? this.isFavorite, + fileMetadataWidth: fileMetadataWidth ?? this.fileMetadataWidth, + fileMetadataHeight: fileMetadataHeight ?? this.fileMetadataHeight, + faceDetections: faceDetections ?? this.faceDetections, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (rowId.present) { + map['row_id'] = Variable(rowId.value); + } + if (parent.present) { + map['parent'] = Variable(parent.value); + } + if (relativePath.present) { + map['relative_path'] = Variable(relativePath.value); + } + if (fileId.present) { + map['file_id'] = Variable(fileId.value); + } + if (contentLength.present) { + map['content_length'] = Variable(contentLength.value); + } + if (contentType.present) { + map['content_type'] = Variable(contentType.value); + } + if (etag.present) { + map['etag'] = Variable(etag.value); + } + if (lastModified.present) { + final converter = $RecognizeFaceItemsTable.$converterlastModifiedn; + map['last_modified'] = + Variable(converter.toSql(lastModified.value)); + } + if (hasPreview.present) { + map['has_preview'] = Variable(hasPreview.value); + } + if (realPath.present) { + map['real_path'] = Variable(realPath.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (fileMetadataWidth.present) { + map['file_metadata_width'] = Variable(fileMetadataWidth.value); + } + if (fileMetadataHeight.present) { + map['file_metadata_height'] = Variable(fileMetadataHeight.value); + } + if (faceDetections.present) { + map['face_detections'] = Variable(faceDetections.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RecognizeFaceItemsCompanion(') + ..write('rowId: $rowId, ') + ..write('parent: $parent, ') + ..write('relativePath: $relativePath, ') + ..write('fileId: $fileId, ') + ..write('contentLength: $contentLength, ') + ..write('contentType: $contentType, ') + ..write('etag: $etag, ') + ..write('lastModified: $lastModified, ') + ..write('hasPreview: $hasPreview, ') + ..write('realPath: $realPath, ') + ..write('isFavorite: $isFavorite, ') + ..write('fileMetadataWidth: $fileMetadataWidth, ') + ..write('fileMetadataHeight: $fileMetadataHeight, ') + ..write('faceDetections: $faceDetections') + ..write(')')) + .toString(); + } +} + abstract class _$SqliteDb extends GeneratedDatabase { _$SqliteDb(QueryExecutor e) : super(e); late final $ServersTable servers = $ServersTable(this); @@ -5305,6 +6219,9 @@ abstract class _$SqliteDb extends GeneratedDatabase { $FaceRecognitionPersonsTable(this); late final $NcAlbumsTable ncAlbums = $NcAlbumsTable(this); late final $NcAlbumItemsTable ncAlbumItems = $NcAlbumItemsTable(this); + late final $RecognizeFacesTable recognizeFaces = $RecognizeFacesTable(this); + late final $RecognizeFaceItemsTable recognizeFaceItems = + $RecognizeFaceItemsTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -5323,7 +6240,9 @@ abstract class _$SqliteDb extends GeneratedDatabase { tags, faceRecognitionPersons, ncAlbums, - ncAlbumItems + ncAlbumItems, + recognizeFaces, + recognizeFaceItems ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules( @@ -5433,6 +6352,20 @@ abstract class _$SqliteDb extends GeneratedDatabase { TableUpdate('nc_album_items', kind: UpdateKind.delete), ], ), + WritePropagation( + on: TableUpdateQuery.onTableName('accounts', + limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('recognize_faces', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('recognize_faces', + limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('recognize_face_items', kind: UpdateKind.delete), + ], + ), ], ); } diff --git a/app/lib/entity/sqlite/database_extension.dart b/app/lib/entity/sqlite/database_extension.dart index f427e6bb..6b0c06f6 100644 --- a/app/lib/entity/sqlite/database_extension.dart +++ b/app/lib/entity/sqlite/database_extension.dart @@ -581,6 +581,92 @@ extension SqliteDbExtension on SqliteDb { } } + Future> allRecognizeFaces({ + required ByAccount account, + }) { + assert((account.sqlAccount != null) != (account.appAccount != null)); + if (account.sqlAccount != null) { + final query = select(recognizeFaces) + ..where((t) => t.account.equals(account.sqlAccount!.rowId)); + return query.get(); + } else { + final query = select(recognizeFaces).join([ + innerJoin(accounts, accounts.rowId.equalsExp(recognizeFaces.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(recognizeFaces)).get(); + } + } + + Future recognizeFaceByLabel({ + required ByAccount account, + required String label, + }) { + assert((account.sqlAccount != null) != (account.appAccount != null)); + if (account.sqlAccount != null) { + final query = select(recognizeFaces) + ..where((t) => t.account.equals(account.sqlAccount!.rowId)) + ..where((t) => t.label.equals(label)); + return query.getSingle(); + } else { + final query = select(recognizeFaces).join([ + innerJoin(accounts, accounts.rowId.equalsExp(recognizeFaces.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(recognizeFaces.label.equals(label)); + return query.map((r) => r.readTable(recognizeFaces)).getSingle(); + } + } + + Future> recognizeFaceItemsByParentLabel({ + required ByAccount account, + required String label, + List? orderBy, + int? limit, + int? offset, + }) { + assert((account.sqlAccount != null) != (account.appAccount != null)); + final query = select(recognizeFaceItems).join([ + innerJoin(recognizeFaces, + recognizeFaces.rowId.equalsExp(recognizeFaceItems.parent), + useColumns: false), + ]); + if (account.sqlAccount != null) { + query + ..where(recognizeFaces.account.equals(account.sqlAccount!.rowId)) + ..where(recognizeFaces.label.equals(label)); + } else { + query + ..join([ + innerJoin(accounts, accounts.rowId.equalsExp(recognizeFaces.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(recognizeFaces.label.equals(label)); + } + if (orderBy != null) { + query.orderBy(orderBy); + if (limit != null) { + query.limit(limit, offset: offset); + } + } + return query.map((r) => r.readTable(recognizeFaceItems)).get(); + } + Future countMissingMetadataByFileIds({ Account? sqlAccount, app.Account? appAccount, diff --git a/app/lib/entity/sqlite/table.dart b/app/lib/entity/sqlite/table.dart index 64518f8a..b8ae3d6d 100644 --- a/app/lib/entity/sqlite/table.dart +++ b/app/lib/entity/sqlite/table.dart @@ -1,4 +1,8 @@ import 'package:drift/drift.dart'; +import 'package:nc_photos/entity/sqlite/database.dart'; +import 'package:np_codegen/np_codegen.dart'; + +part 'table.g.dart'; class Servers extends Table { IntColumn get rowId => integer().autoIncrement()(); @@ -233,6 +237,43 @@ class FaceRecognitionPersons extends Table { ]; } +class RecognizeFaces extends Table { + IntColumn get rowId => integer().autoIncrement()(); + IntColumn get account => + integer().references(Accounts, #rowId, onDelete: KeyAction.cascade)(); + TextColumn get label => text()(); + + @override + List>? get uniqueKeys => [ + {account, label}, + ]; +} + +@DriftTableSort("SqliteDb") +class RecognizeFaceItems extends Table { + IntColumn get rowId => integer().autoIncrement()(); + IntColumn get parent => integer() + .references(RecognizeFaces, #rowId, onDelete: KeyAction.cascade)(); + TextColumn get relativePath => text()(); + IntColumn get fileId => integer()(); + IntColumn get contentLength => integer().nullable()(); + TextColumn get contentType => text().nullable()(); + TextColumn get etag => text().nullable()(); + DateTimeColumn get lastModified => + dateTime().map(const SqliteDateTimeConverter()).nullable()(); + BoolColumn get hasPreview => boolean().nullable()(); + TextColumn get realPath => text().nullable()(); + BoolColumn get isFavorite => boolean().nullable()(); + IntColumn get fileMetadataWidth => integer().nullable()(); + IntColumn get fileMetadataHeight => integer().nullable()(); + TextColumn get faceDetections => text().nullable()(); + + @override + List>? get uniqueKeys => [ + {parent, fileId}, + ]; +} + class SqliteDateTimeConverter extends TypeConverter { const SqliteDateTimeConverter(); diff --git a/app/lib/entity/sqlite/table.g.dart b/app/lib/entity/sqlite/table.g.dart new file mode 100644 index 00000000..c4fcff84 --- /dev/null +++ b/app/lib/entity/sqlite/table.g.dart @@ -0,0 +1,104 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'table.dart'; + +// ************************************************************************** +// DriftTableSortGenerator +// ************************************************************************** + +enum RecognizeFaceItemSort { + rowIdAsc, + rowIdDesc, + parentAsc, + parentDesc, + relativePathAsc, + relativePathDesc, + fileIdAsc, + fileIdDesc, + contentLengthAsc, + contentLengthDesc, + contentTypeAsc, + contentTypeDesc, + etagAsc, + etagDesc, + lastModifiedAsc, + lastModifiedDesc, + hasPreviewAsc, + hasPreviewDesc, + realPathAsc, + realPathDesc, + isFavoriteAsc, + isFavoriteDesc, + fileMetadataWidthAsc, + fileMetadataWidthDesc, + fileMetadataHeightAsc, + fileMetadataHeightDesc, + faceDetectionsAsc, + faceDetectionsDesc, +} + +extension RecognizeFaceItemSortIterableExtension + on Iterable { + Iterable toOrderingItem(SqliteDb db) { + return map((s) { + switch (s) { + case RecognizeFaceItemSort.rowIdAsc: + return OrderingTerm.asc(db.recognizeFaceItems.rowId); + case RecognizeFaceItemSort.rowIdDesc: + return OrderingTerm.desc(db.recognizeFaceItems.rowId); + case RecognizeFaceItemSort.parentAsc: + return OrderingTerm.asc(db.recognizeFaceItems.parent); + case RecognizeFaceItemSort.parentDesc: + return OrderingTerm.desc(db.recognizeFaceItems.parent); + case RecognizeFaceItemSort.relativePathAsc: + return OrderingTerm.asc(db.recognizeFaceItems.relativePath); + case RecognizeFaceItemSort.relativePathDesc: + return OrderingTerm.desc(db.recognizeFaceItems.relativePath); + case RecognizeFaceItemSort.fileIdAsc: + return OrderingTerm.asc(db.recognizeFaceItems.fileId); + case RecognizeFaceItemSort.fileIdDesc: + return OrderingTerm.desc(db.recognizeFaceItems.fileId); + case RecognizeFaceItemSort.contentLengthAsc: + return OrderingTerm.asc(db.recognizeFaceItems.contentLength); + case RecognizeFaceItemSort.contentLengthDesc: + return OrderingTerm.desc(db.recognizeFaceItems.contentLength); + case RecognizeFaceItemSort.contentTypeAsc: + return OrderingTerm.asc(db.recognizeFaceItems.contentType); + case RecognizeFaceItemSort.contentTypeDesc: + return OrderingTerm.desc(db.recognizeFaceItems.contentType); + case RecognizeFaceItemSort.etagAsc: + return OrderingTerm.asc(db.recognizeFaceItems.etag); + case RecognizeFaceItemSort.etagDesc: + return OrderingTerm.desc(db.recognizeFaceItems.etag); + case RecognizeFaceItemSort.lastModifiedAsc: + return OrderingTerm.asc(db.recognizeFaceItems.lastModified); + case RecognizeFaceItemSort.lastModifiedDesc: + return OrderingTerm.desc(db.recognizeFaceItems.lastModified); + case RecognizeFaceItemSort.hasPreviewAsc: + return OrderingTerm.asc(db.recognizeFaceItems.hasPreview); + case RecognizeFaceItemSort.hasPreviewDesc: + return OrderingTerm.desc(db.recognizeFaceItems.hasPreview); + case RecognizeFaceItemSort.realPathAsc: + return OrderingTerm.asc(db.recognizeFaceItems.realPath); + case RecognizeFaceItemSort.realPathDesc: + return OrderingTerm.desc(db.recognizeFaceItems.realPath); + case RecognizeFaceItemSort.isFavoriteAsc: + return OrderingTerm.asc(db.recognizeFaceItems.isFavorite); + case RecognizeFaceItemSort.isFavoriteDesc: + return OrderingTerm.desc(db.recognizeFaceItems.isFavorite); + case RecognizeFaceItemSort.fileMetadataWidthAsc: + return OrderingTerm.asc(db.recognizeFaceItems.fileMetadataWidth); + case RecognizeFaceItemSort.fileMetadataWidthDesc: + return OrderingTerm.desc(db.recognizeFaceItems.fileMetadataWidth); + case RecognizeFaceItemSort.fileMetadataHeightAsc: + return OrderingTerm.asc(db.recognizeFaceItems.fileMetadataHeight); + case RecognizeFaceItemSort.fileMetadataHeightDesc: + return OrderingTerm.desc(db.recognizeFaceItems.fileMetadataHeight); + case RecognizeFaceItemSort.faceDetectionsAsc: + return OrderingTerm.asc(db.recognizeFaceItems.faceDetections); + case RecognizeFaceItemSort.faceDetectionsDesc: + return OrderingTerm.desc(db.recognizeFaceItems.faceDetections); + } + }); + } +} diff --git a/app/lib/entity/sqlite/type_converter.dart b/app/lib/entity/sqlite/type_converter.dart index f12c3a0e..e5767796 100644 --- a/app/lib/entity/sqlite/type_converter.dart +++ b/app/lib/entity/sqlite/type_converter.dart @@ -11,6 +11,8 @@ 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/nc_album_item.dart'; +import 'package:nc_photos/entity/recognize_face.dart'; +import 'package:nc_photos/entity/recognize_face_item.dart'; import 'package:nc_photos/entity/sqlite/database.dart' as sql; import 'package:nc_photos/entity/tag.dart'; import 'package:nc_photos/iterable_extension.dart'; @@ -18,6 +20,7 @@ import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/or_null.dart'; import 'package:np_api/np_api.dart' as api; import 'package:np_common/ci_string.dart'; +import 'package:np_common/type.dart'; extension SqlTagListExtension on List { Future> convertToAppTag() { @@ -52,6 +55,22 @@ extension AppFaceRecognitionPersonListExtension on List { } } +extension SqlRecognizeFaceListExtension on List { + Future> convertToAppRecognizeFace() { + return computeAll(SqliteRecognizeFaceConverter.fromSql); + } +} + +extension AppRecognizeFaceListExtension on List { + Future> convertToRecognizeFaceCompanion( + sql.Account? dbAccount) { + return map((f) => { + "account": dbAccount, + "face": f, + }).computeAll(_convertAppRecognizeFace); + } +} + class SqliteAlbumConverter { static Album fromSql( sql.Album album, File albumFile, List shares) { @@ -326,6 +345,62 @@ class SqliteNcAlbumItemConverter { ); } +class SqliteRecognizeFaceConverter { + static RecognizeFace fromSql(sql.RecognizeFace face) => RecognizeFace( + label: face.label, + ); + + static sql.RecognizeFacesCompanion toSql( + sql.Account? dbAccount, RecognizeFace face) => + sql.RecognizeFacesCompanion( + account: + dbAccount == null ? const Value.absent() : Value(dbAccount.rowId), + label: Value(face.label), + ); +} + +class SqliteRecognizeFaceItemConverter { + static RecognizeFaceItem fromSql( + String userId, String faceLabel, sql.RecognizeFaceItem item) => + RecognizeFaceItem( + path: + "${api.ApiRecognize.path}/$userId/faces/$faceLabel/${item.relativePath}", + fileId: item.fileId, + contentLength: item.contentLength, + contentType: item.contentType, + etag: item.etag, + lastModified: item.lastModified, + hasPreview: item.hasPreview, + realPath: item.realPath, + isFavorite: item.isFavorite, + fileMetadataWidth: item.fileMetadataWidth, + fileMetadataHeight: item.fileMetadataHeight, + faceDetections: item.faceDetections + ?.run((obj) => (jsonDecode(obj) as List).cast()), + ); + + static sql.RecognizeFaceItemsCompanion toSql( + sql.RecognizeFace parent, + RecognizeFaceItem item, + ) => + sql.RecognizeFaceItemsCompanion( + parent: Value(parent.rowId), + relativePath: Value(item.strippedPath), + fileId: Value(item.fileId), + contentLength: Value(item.contentLength), + contentType: Value(item.contentType), + etag: Value(item.etag), + lastModified: Value(item.lastModified), + hasPreview: Value(item.hasPreview), + realPath: Value(item.realPath), + isFavorite: Value(item.isFavorite), + fileMetadataWidth: Value(item.fileMetadataWidth), + fileMetadataHeight: Value(item.fileMetadataHeight), + faceDetections: + Value(item.faceDetections?.run((obj) => jsonEncode(obj))), + ); +} + sql.TagsCompanion _convertAppTag(Map map) { final account = map["account"] as sql.Account?; final tag = map["tag"] as Tag; @@ -337,3 +412,9 @@ sql.FaceRecognitionPersonsCompanion _convertAppFaceRecognitionPerson(Map map) { final person = map["person"] as FaceRecognitionPerson; return SqliteFaceRecognitionPersonConverter.toSql(account, person); } + +sql.RecognizeFacesCompanion _convertAppRecognizeFace(Map map) { + final account = map["account"] as sql.Account?; + final face = map["face"] as RecognizeFace; + return SqliteRecognizeFaceConverter.toSql(account, face); +} diff --git a/app/lib/use_case/face_recognition_person/sync_face_recognition_person.dart b/app/lib/use_case/face_recognition_person/sync_face_recognition_person.dart index 3226bcb8..a6ea2073 100644 --- a/app/lib/use_case/face_recognition_person/sync_face_recognition_person.dart +++ b/app/lib/use_case/face_recognition_person/sync_face_recognition_person.dart @@ -23,10 +23,13 @@ class SyncFaceRecognitionPerson { /// Return if any people were updated Future call(Account account) async { _log.info("[call] Sync people with remote"); + int personSorter(FaceRecognitionPerson a, FaceRecognitionPerson b) => + a.name.compareTo(b.name); late final List remote; try { - remote = - await ListFaceRecognitionPerson(_c.withRemoteRepo())(account).last; + remote = (await ListFaceRecognitionPerson(_c.withRemoteRepo())(account) + .last) + ..sort(personSorter); } catch (e) { if (e is ApiException && e.response.statusCode == 404) { // face recognition app probably not installed, ignore @@ -35,10 +38,9 @@ class SyncFaceRecognitionPerson { } rethrow; } - final cache = - await ListFaceRecognitionPerson(_c.withLocalRepo())(account).last; - int personSorter(FaceRecognitionPerson a, FaceRecognitionPerson b) => - a.name.compareTo(b.name); + final cache = (await ListFaceRecognitionPerson(_c.withLocalRepo())(account) + .last) + ..sort(personSorter); final diff = list_util.diffWith(cache, remote, personSorter); final inserts = diff.onlyInB; _log.info("[call] New people: ${inserts.toReadableString()}"); diff --git a/app/lib/use_case/recognize_face/list_recognize_face.dart b/app/lib/use_case/recognize_face/list_recognize_face.dart new file mode 100644 index 00000000..38d66815 --- /dev/null +++ b/app/lib/use_case/recognize_face/list_recognize_face.dart @@ -0,0 +1,13 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/recognize_face.dart'; + +class ListRecognizeFace { + const ListRecognizeFace(this._c); + + /// List all [RecognizeFace]s belonging to [account] + Stream> call(Account account) => + _c.recognizeFaceRepo.getFaces(account); + + final DiContainer _c; +} diff --git a/app/lib/use_case/recognize_face/list_recognize_face_item.dart b/app/lib/use_case/recognize_face/list_recognize_face_item.dart new file mode 100644 index 00000000..c3a07a7f --- /dev/null +++ b/app/lib/use_case/recognize_face/list_recognize_face_item.dart @@ -0,0 +1,29 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/recognize_face.dart'; +import 'package:nc_photos/entity/recognize_face_item.dart'; +import 'package:np_common/type.dart'; + +class ListRecognizeFaceItem { + const ListRecognizeFaceItem(this._c); + + /// List all [RecognizeFaceItem]s belonging to [face] + Stream> call(Account account, RecognizeFace face) => + _c.recognizeFaceRepo.getItems(account, face); + + final DiContainer _c; +} + +class ListMultipleRecognizeFaceItem { + const ListMultipleRecognizeFaceItem(this._c); + + /// List all [RecognizeFaceItem]s belonging to each face + Stream>> call( + Account account, + List faces, { + ErrorWithValueHandler? onError, + }) => + _c.recognizeFaceRepo.getMultiFaceItems(account, faces, onError: onError); + + final DiContainer _c; +} diff --git a/app/lib/use_case/recognize_face/sync_recognize_face.dart b/app/lib/use_case/recognize_face/sync_recognize_face.dart new file mode 100644 index 00000000..3ad1bf12 --- /dev/null +++ b/app/lib/use_case/recognize_face/sync_recognize_face.dart @@ -0,0 +1,288 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart' as sql; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/recognize_face.dart'; +import 'package:nc_photos/entity/recognize_face_item.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/iterable_extension.dart'; +import 'package:nc_photos/list_util.dart' as list_util; +import 'package:nc_photos/map_extension.dart'; +import 'package:nc_photos/use_case/recognize_face/list_recognize_face.dart'; +import 'package:nc_photos/use_case/recognize_face/list_recognize_face_item.dart'; +import 'package:np_codegen/np_codegen.dart'; + +part 'sync_recognize_face.g.dart'; + +@npLog +class SyncRecognizeFace { + const SyncRecognizeFace(this._c); + + /// Sync people in cache db with remote server + /// + /// Return if any people were updated + Future call(Account account) async { + _log.info("[call] Sync people with remote"); + final faces = await _getFaceResults(account); + if (faces == null) { + return false; + } + var shouldUpdate = faces.inserts.isNotEmpty || + faces.deletes.isNotEmpty || + faces.updates.isNotEmpty; + final items = + await _getFaceItemResults(account, faces.results.values.toList()); + shouldUpdate = shouldUpdate || + items.values.any((e) => + e.inserts.isNotEmpty || + e.deletes.isNotEmpty || + e.updates.isNotEmpty); + if (!shouldUpdate) { + return false; + } + + await _c.sqliteDb.use((db) async { + final dbAccount = await db.accountOf(account); + await db.batch((batch) { + for (final d in faces.deletes) { + batch.deleteWhere( + db.recognizeFaces, + (sql.$RecognizeFacesTable t) => + t.account.equals(dbAccount.rowId) & + t.label.equals(faces.results[d]!.label), + ); + } + for (final u in faces.updates) { + batch.update( + db.recognizeFaces, + sql.RecognizeFacesCompanion( + label: sql.Value(faces.results[u]!.label), + ), + where: (sql.$RecognizeFacesTable t) => + t.account.equals(dbAccount.rowId) & + t.label.equals(faces.results[u]!.label), + ); + } + for (final i in faces.inserts) { + batch.insert( + db.recognizeFaces, + SqliteRecognizeFaceConverter.toSql(dbAccount, faces.results[i]!), + mode: sql.InsertMode.insertOrIgnore, + ); + } + }); + + // update each item + for (final f in faces.results.values) { + try { + await _syncDbForFaceItem(db, dbAccount, f, items[f]!); + } catch (e, stackTrace) { + _log.shout("[call] Failed to update db for face: $f", e, stackTrace); + } + } + }); + return true; + } + + Future<_FaceResult?> _getFaceResults(Account account) async { + int faceSorter(RecognizeFace a, RecognizeFace b) => + a.label.compareTo(b.label); + late final List remote; + try { + remote = (await ListRecognizeFace(_c.withRemoteRepo())(account).last) + ..sort(faceSorter); + } catch (e) { + if (e is ApiException && e.response.statusCode == 404) { + // recognize app probably not installed, ignore + _log.info("[_getFaceResults] Recognize app not installed"); + return null; + } + rethrow; + } + final cache = (await ListRecognizeFace(_c.withLocalRepo())(account).last) + ..sort(faceSorter); + final diff = list_util.diffWith(cache, remote, faceSorter); + final inserts = diff.onlyInB; + _log.info("[_getFaceResults] New face: ${inserts.toReadableString()}"); + final deletes = diff.onlyInA; + _log.info("[_getFaceResults] Removed face: ${deletes.toReadableString()}"); + final updates = remote.where((r) { + final c = cache.firstWhereOrNull((c) => c.label == r.label); + return c != null && c != r; + }).toList(); + _log.info("[_getFaceResults] Updated face: ${updates.toReadableString()}"); + return _FaceResult( + results: remote.map((e) => MapEntry(e.label, e)).toMap(), + inserts: inserts.map((e) => e.label).toList(), + updates: updates.map((e) => e.label).toList(), + deletes: deletes.map((e) => e.label).toList(), + ); + } + + Future> _getFaceItemResults( + Account account, List faces) async { + Object? firstError; + StackTrace? firstStackTrace; + final remote = await ListMultipleRecognizeFaceItem(_c.withRemoteRepo())( + account, + faces, + onError: (f, e, stackTrace) { + _log.severe( + "[_getFaceItemResults] Failed while listing remote face: $f", + e, + stackTrace, + ); + if (firstError == null) { + firstError = e; + firstStackTrace = stackTrace; + } + }, + ).last; + if (firstError != null) { + Error.throwWithStackTrace( + firstError!, firstStackTrace ?? StackTrace.current); + } + final cache = await ListMultipleRecognizeFaceItem(_c.withLocalRepo())( + account, + faces, + onError: (f, e, stackTrace) { + _log.severe("[_getFaceItemResults] Failed while listing cache face: $f", + e, stackTrace); + }, + ).last; + + int itemSorter(RecognizeFaceItem a, RecognizeFaceItem b) => + a.fileId.compareTo(b.fileId); + final results = {}; + for (final f in faces) { + final thisCache = (cache[f] ?? [])..sort(itemSorter); + final thisRemote = (remote[f] ?? [])..sort(itemSorter); + final diff = list_util.diffWith( + thisCache, thisRemote, itemSorter); + final inserts = diff.onlyInB; + _log.info( + "[_getFaceItemResults] New item: ${inserts.toReadableString()}"); + final deletes = diff.onlyInA; + _log.info( + "[_getFaceItemResults] Removed item: ${deletes.toReadableString()}"); + final updates = thisRemote.where((r) { + final c = thisCache.firstWhereOrNull((c) => c.fileId == r.fileId); + return c != null && c != r; + }).toList(); + _log.info( + "[_getFaceItemResults] Updated item: ${updates.toReadableString()}"); + results[f] = _FaceItemResult( + results: thisRemote.map((e) => MapEntry(e.fileId, e)).toMap(), + inserts: inserts.map((e) => e.fileId).toList(), + updates: updates.map((e) => e.fileId).toList(), + deletes: deletes.map((e) => e.fileId).toList(), + ); + } + return results; + } + + // Future<_FaceItemResult?> _getFaceItemResults( + // Account account, RecognizeFace face) async { + // late final List remote; + // try { + // remote = + // await ListRecognizeFaceItem(_c.withRemoteRepo())(account, face).last; + // } catch (e) { + // if (e is ApiException && e.response.statusCode == 404) { + // // recognize app probably not installed, ignore + // _log.info("[_getFaceItemResults] Recognize app not installed"); + // return null; + // } + // rethrow; + // } + // final cache = + // await ListRecognizeFaceItem(_c.withLocalRepo())(account, face).last; + // int itemSorter(RecognizeFaceItem a, RecognizeFaceItem b) => + // a.fileId.compareTo(b.fileId); + // final diff = list_util.diffWith(cache, remote, itemSorter); + // final inserts = diff.onlyInB; + // _log.info("[_getFaceItemResults] New face: ${inserts.toReadableString()}"); + // final deletes = diff.onlyInA; + // _log.info( + // "[_getFaceItemResults] Removed face: ${deletes.toReadableString()}"); + // final updates = remote.where((r) { + // final c = cache.firstWhereOrNull((c) => c.fileId == r.fileId); + // return c != null && c != r; + // }).toList(); + // _log.info( + // "[_getFaceItemResults] Updated face: ${updates.toReadableString()}"); + // return _FaceItemResult( + // results: remote.map((e) => MapEntry(e.fileId, e)).toMap(), + // inserts: inserts.map((e) => e.fileId).toList(), + // updates: updates.map((e) => e.fileId).toList(), + // deletes: deletes.map((e) => e.fileId).toList(), + // ); + // } + + Future _syncDbForFaceItem(sql.SqliteDb db, sql.Account dbAccount, + RecognizeFace face, _FaceItemResult item) async { + await db.transaction(() async { + final dbFace = await db.recognizeFaceByLabel( + account: sql.ByAccount.sql(dbAccount), + label: face.label, + ); + await db.batch((batch) { + for (final d in item.deletes) { + batch.deleteWhere( + db.recognizeFaceItems, + (sql.$RecognizeFaceItemsTable t) => + t.parent.equals(dbFace.rowId) & t.fileId.equals(d), + ); + } + for (final u in item.updates) { + batch.update( + db.recognizeFaceItems, + SqliteRecognizeFaceItemConverter.toSql(dbFace, item.results[u]!), + where: (sql.$RecognizeFaceItemsTable t) => + t.parent.equals(dbFace.rowId) & t.fileId.equals(u), + ); + } + for (final i in item.inserts) { + batch.insert( + db.recognizeFaceItems, + SqliteRecognizeFaceItemConverter.toSql(dbFace, item.results[i]!), + mode: sql.InsertMode.insertOrIgnore, + ); + } + }); + }); + } + + final DiContainer _c; +} + +class _FaceResult { + const _FaceResult({ + required this.results, + required this.inserts, + required this.updates, + required this.deletes, + }); + + final Map results; + final List inserts; + final List updates; + final List deletes; +} + +class _FaceItemResult { + const _FaceItemResult({ + required this.results, + required this.inserts, + required this.updates, + required this.deletes, + }); + + final Map results; + final List inserts; + final List updates; + final List deletes; +} diff --git a/app/lib/use_case/recognize_face/sync_recognize_face.g.dart b/app/lib/use_case/recognize_face/sync_recognize_face.g.dart new file mode 100644 index 00000000..afab0565 --- /dev/null +++ b/app/lib/use_case/recognize_face/sync_recognize_face.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync_recognize_face.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$SyncRecognizeFaceNpLog on SyncRecognizeFace { + // ignore: unused_element + Logger get _log => log; + + static final log = + Logger("use_case.recognize_face.sync_recognize_face.SyncRecognizeFace"); +} diff --git a/app/lib/widget/network_thumbnail.dart b/app/lib/widget/network_thumbnail.dart index 50e5a413..0fad4192 100644 --- a/app/lib/widget/network_thumbnail.dart +++ b/app/lib/widget/network_thumbnail.dart @@ -1,12 +1,14 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/cache_manager_util.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/np_api_util.dart'; +import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/widget/cached_network_image_mod.dart' as mod; /// A square thumbnail widget for a file class NetworkRectThumbnail extends StatelessWidget { @@ -16,6 +18,7 @@ class NetworkRectThumbnail extends StatelessWidget { required this.imageUrl, this.dimension, required this.errorBuilder, + this.onSize, }); static String imageUrlForFile(Account account, FileDescriptor file) => @@ -41,10 +44,9 @@ class NetworkRectThumbnail extends StatelessWidget { final child = FittedBox( clipBehavior: Clip.hardEdge, fit: BoxFit.cover, - child: CachedNetworkImage( + child: mod.CachedNetworkImage( cacheManager: ThumbnailCacheManager.inst, imageUrl: imageUrl, - // imageUrl: "", httpHeaders: { "Authorization": AuthUtil.fromAccount(account).toHeaderValue(), }, @@ -55,6 +57,12 @@ class NetworkRectThumbnail extends StatelessWidget { dimension: dimension, child: errorBuilder(context), ), + imageBuilder: (_, child, __) { + return _SizeObserver( + onSize: onSize, + child: child, + ); + }, ), ); if (dimension != null) { @@ -74,4 +82,48 @@ class NetworkRectThumbnail extends StatelessWidget { final String imageUrl; final double? dimension; final Widget Function(BuildContext context) errorBuilder; + final ValueChanged? onSize; +} + +class _SizeObserver extends SingleChildRenderObjectWidget { + const _SizeObserver({ + super.child, + this.onSize, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderSizeChangedWithCallback( + onLayoutChangedCallback: () { + if (onSize != null) { + final size = context.findRenderObject()?.as()?.size; + if (size != null) { + onSize?.call(size); + } + } + }, + ); + } + + final ValueChanged? onSize; +} + +class _RenderSizeChangedWithCallback extends RenderProxyBox { + _RenderSizeChangedWithCallback({ + RenderBox? child, + required this.onLayoutChangedCallback, + }) : super(child); + + @override + void performLayout() { + super.performLayout(); + if (size != _oldSize) { + onLayoutChangedCallback(); + } + _oldSize = size; + } + + final VoidCallback onLayoutChangedCallback; + + Size? _oldSize; } diff --git a/app/lib/widget/search_landing.dart b/app/lib/widget/search_landing.dart index a8593a87..effda9a0 100644 --- a/app/lib/widget/search_landing.dart +++ b/app/lib/widget/search_landing.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -25,6 +27,8 @@ import 'package:nc_photos/widget/places_browser.dart'; import 'package:np_codegen/np_codegen.dart'; part 'search_landing.g.dart'; +part 'search_landing/type.dart'; +part 'search_landing/view.dart'; class SearchLanding extends StatefulWidget { const SearchLanding({ @@ -263,8 +267,7 @@ class _SearchLandingState extends State { .take(10) .map((e) => _LandingPersonItem( account: widget.account, - name: e.name, - faceUrl: e.getCoverUrl(k.faceThumbSize, k.faceThumbSize), + person: e, onTap: () => _onPersonItemTap(e), )) .toList(); @@ -303,92 +306,32 @@ class _SearchLandingState extends State { var _locationItems = <_LandingLocationItem>[]; } -class _LandingPersonItem { - _LandingPersonItem({ - required this.account, - required this.name, - required this.faceUrl, - this.onTap, - }); - - Widget buildWidget(BuildContext context) => _LandingItemWidget( - account: account, - label: name, - coverUrl: faceUrl, - onTap: onTap, - fallbackBuilder: (_) => Icon( - Icons.person, - color: Theme.of(context).listPlaceholderForegroundColor, - ), - ); - - final Account account; - final String name; - final String? faceUrl; - final VoidCallback? onTap; -} - -class _LandingLocationItem { - const _LandingLocationItem({ - required this.account, - required this.name, - required this.thumbUrl, - this.onTap, - }); - - Widget buildWidget(BuildContext context) => _LandingItemWidget( - account: account, - label: name, - coverUrl: thumbUrl, - onTap: onTap, - fallbackBuilder: (_) => Icon( - Icons.location_on, - color: Theme.of(context).listPlaceholderForegroundColor, - ), - ); - - final Account account; - final String name; - final String thumbUrl; - final VoidCallback? onTap; -} - -class _LandingItemWidget extends StatelessWidget { - const _LandingItemWidget({ - Key? key, +class _LandingPersonWidget extends StatelessWidget { + const _LandingPersonWidget({ required this.account, + required this.person, required this.label, required this.coverUrl, - required this.fallbackBuilder, this.onTap, - }) : super(key: key); + }); @override - build(BuildContext context) { + Widget build(BuildContext context) { final content = Padding( padding: const EdgeInsets.all(4), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Expanded( - child: Center( - child: AspectRatio( - aspectRatio: 1, - child: _buildCoverImage(context), - ), + Center( + child: _PersonCoverImage( + dimension: 72, + account: account, + person: person, + coverUrl: coverUrl, ), ), const SizedBox(height: 8), - SizedBox( - width: 88, - child: Text( - label + "\n", - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), + Expanded(child: _Label(label: label)), ], ), ); @@ -402,37 +345,74 @@ class _LandingItemWidget extends StatelessWidget { } } - Widget _buildCoverImage(BuildContext context) { - Widget cover; - Widget buildPlaceholder() => Padding( - padding: const EdgeInsets.all(8), - child: fallbackBuilder(context), - ); - try { - cover = NetworkRectThumbnail( - account: account, - imageUrl: coverUrl!, - errorBuilder: (_) => buildPlaceholder(), - ); - } catch (_) { - cover = FittedBox( - child: buildPlaceholder(), - ); - } + final Account account; + final Person person; + final String label; + final String? coverUrl; + final VoidCallback? onTap; +} - return ClipRRect( - borderRadius: BorderRadius.circular(128), - child: Container( - color: Theme.of(context).listPlaceholderBackgroundColor, - constraints: const BoxConstraints.expand(), - child: cover, +class _LandingLocationWidget extends StatelessWidget { + const _LandingLocationWidget({ + required this.account, + required this.label, + required this.coverUrl, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final content = Padding( + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Center( + child: _LocationCoverImage( + dimension: 72, + account: account, + coverUrl: coverUrl, + ), + ), + const SizedBox(height: 8), + Expanded(child: _Label(label: label)), + ], ), ); + if (onTap != null) { + return InkWell( + onTap: onTap, + child: content, + ); + } else { + return content; + } } final Account account; final String label; final String? coverUrl; - final Widget Function(BuildContext context) fallbackBuilder; final VoidCallback? onTap; } + +class _Label extends StatelessWidget { + const _Label({ + required this.label, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 88, + child: Text( + label + "\n", + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + } + + final String label; +} diff --git a/app/lib/widget/search_landing/type.dart b/app/lib/widget/search_landing/type.dart new file mode 100644 index 00000000..061bb62e --- /dev/null +++ b/app/lib/widget/search_landing/type.dart @@ -0,0 +1,49 @@ +part of '../search_landing.dart'; + +class _LandingPersonItem { + _LandingPersonItem({ + required this.account, + required this.person, + this.onTap, + }) : name = person.name, + faceUrl = person.getCoverUrl( + k.photoLargeSize, + k.photoLargeSize, + isKeepAspectRatio: true, + ); + + Widget buildWidget(BuildContext context) => _LandingPersonWidget( + account: account, + person: person, + label: name, + coverUrl: faceUrl, + onTap: onTap, + ); + + final Account account; + final Person person; + final String name; + final String? faceUrl; + final VoidCallback? onTap; +} + +class _LandingLocationItem { + const _LandingLocationItem({ + required this.account, + required this.name, + required this.thumbUrl, + this.onTap, + }); + + Widget buildWidget(BuildContext context) => _LandingLocationWidget( + account: account, + label: name, + coverUrl: thumbUrl, + onTap: onTap, + ); + + final Account account; + final String name; + final String thumbUrl; + final VoidCallback? onTap; +} diff --git a/app/lib/widget/search_landing/view.dart b/app/lib/widget/search_landing/view.dart new file mode 100644 index 00000000..4391462b --- /dev/null +++ b/app/lib/widget/search_landing/view.dart @@ -0,0 +1,143 @@ +part of '../search_landing.dart'; + +class _PersonCoverPlaceholder extends StatelessWidget { + const _PersonCoverPlaceholder(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.person, + color: Theme.of(context).listPlaceholderForegroundColor, + ), + ); + } +} + +class _PersonCoverImage extends StatefulWidget { + const _PersonCoverImage({ + required this.dimension, + required this.account, + required this.coverUrl, + required this.person, + }); + + @override + State createState() => _PersonCoverImageState(); + + final double dimension; + final Account account; + final String? coverUrl; + final Person person; +} + +class _PersonCoverImageState extends State<_PersonCoverImage> { + @override + Widget build(BuildContext context) { + Widget cover; + try { + var m = Matrix4.identity(); + if (_layoutSize != null) { + final ratio = widget.dimension / + math.min(_layoutSize!.width, _layoutSize!.height); + final mm = widget.person.getCoverTransform( + widget.dimension.toInt(), + (_layoutSize!.width * ratio).toInt(), + (_layoutSize!.height * ratio).toInt(), + ); + if (mm != null) { + m = mm; + } + } + cover = Transform( + transform: m, + child: NetworkRectThumbnail( + account: widget.account, + imageUrl: widget.coverUrl!, + errorBuilder: (_) => const _PersonCoverPlaceholder(), + onSize: (size) { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _layoutSize = size; + }); + }); + }, + ), + ); + } catch (_) { + cover = const FittedBox( + child: _PersonCoverPlaceholder(), + ); + } + + return SizedBox.square( + dimension: widget.dimension, + child: ClipRRect( + borderRadius: BorderRadius.circular(widget.dimension / 2), + child: Container( + color: Theme.of(context).listPlaceholderBackgroundColor, + constraints: const BoxConstraints.expand(), + child: cover, + ), + ), + ); + } + + Size? _layoutSize; +} + +class _LocationCoverPlaceholder extends StatelessWidget { + const _LocationCoverPlaceholder(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.location_on, + color: Theme.of(context).listPlaceholderForegroundColor, + ), + ); + } +} + +class _LocationCoverImage extends StatelessWidget { + const _LocationCoverImage({ + required this.dimension, + required this.account, + required this.coverUrl, + }); + + @override + Widget build(BuildContext context) { + Widget cover; + try { + cover = NetworkRectThumbnail( + account: account, + imageUrl: coverUrl!, + errorBuilder: (_) => const _LocationCoverPlaceholder(), + ); + } catch (_) { + cover = const FittedBox( + child: _LocationCoverPlaceholder(), + ); + } + + return SizedBox.square( + dimension: dimension, + child: ClipRRect( + borderRadius: BorderRadius.circular(dimension / 2), + child: Container( + color: Theme.of(context).listPlaceholderBackgroundColor, + constraints: const BoxConstraints.expand(), + child: cover, + ), + ), + ); + } + + final double dimension; + final Account account; + final String? coverUrl; +} diff --git a/app/test/api/entity_converter_test.dart b/app/test/api/entity_converter_test.dart index 32112dc2..28631ce3 100644 --- a/app/test/api/entity_converter_test.dart +++ b/app/test/api/entity_converter_test.dart @@ -1,5 +1,6 @@ import 'package:nc_photos/api/entity_converter.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/recognize_face_item.dart'; import 'package:np_api/np_api.dart' as api; import 'package:test/test.dart'; @@ -15,6 +16,12 @@ void main() { test("nextcloud hosted in subdir", _filesServerHostedInSubdir); }); }); + group("ApiRecognizeFaceItemConverter", () { + group("fromApi", () { + test("minimum", _recognizeFaceItemMinimum); + test("size", _recognizeFaceItemSize); + }); + }); } Future _files() async { @@ -242,3 +249,37 @@ Future _filesServerHostedInSubdir() async { ), ); } + +void _recognizeFaceItemMinimum() { + const apiItem = api.RecognizeFaceItem( + href: "/remote.php/dav/recognize/admin/faces/test/test1.jpg", + fileId: 123, + ); + expect( + ApiRecognizeFaceItemConverter.fromApi(apiItem), + const RecognizeFaceItem( + path: "remote.php/dav/recognize/admin/faces/test/test1.jpg", + fileId: 123, + ), + ); +} + +void _recognizeFaceItemSize() { + const apiItem = api.RecognizeFaceItem( + href: "/remote.php/dav/recognize/admin/faces/test/test1.jpg", + fileId: 123, + fileMetadataSize: { + "width": 1024, + "height": 768, + }, + ); + expect( + ApiRecognizeFaceItemConverter.fromApi(apiItem), + const RecognizeFaceItem( + path: "remote.php/dav/recognize/admin/faces/test/test1.jpg", + fileId: 123, + fileMetadataWidth: 1024, + fileMetadataHeight: 768, + ), + ); +} diff --git a/np_api/lib/np_api.dart b/np_api/lib/np_api.dart index d9f34ce7..e5cdc232 100644 --- a/np_api/lib/np_api.dart +++ b/np_api/lib/np_api.dart @@ -6,6 +6,7 @@ export 'src/entity/favorite_parser.dart'; export 'src/entity/file_parser.dart'; export 'src/entity/nc_album_item_parser.dart'; export 'src/entity/nc_album_parser.dart'; +export 'src/entity/recognize_face_item_parser.dart'; export 'src/entity/recognize_face_parser.dart'; export 'src/entity/share_parser.dart'; export 'src/entity/sharee_parser.dart'; diff --git a/np_api/lib/src/entity/entity.dart b/np_api/lib/src/entity/entity.dart index 351ee231..20eea6e0 100644 --- a/np_api/lib/src/entity/entity.dart +++ b/np_api/lib/src/entity/entity.dart @@ -269,8 +269,7 @@ class RecognizeFaceItem with EquatableMixin { final String? etag; final DateTime? lastModified; final List? faceDetections; - // format currently unknown - final dynamic fileMetadataSize; + final JsonObj? fileMetadataSize; final bool? hasPreview; final String? realPath; final bool? favorite; diff --git a/np_api/lib/src/entity/recognize_face_item_parser.dart b/np_api/lib/src/entity/recognize_face_item_parser.dart index 4cb303a1..7f5da8ca 100644 --- a/np_api/lib/src/entity/recognize_face_item_parser.dart +++ b/np_api/lib/src/entity/recognize_face_item_parser.dart @@ -23,8 +23,7 @@ class RecognizeFaceItemParser extends XmlResponseParser { String? etag; DateTime? lastModified; List? faceDetections; - // format currently unknown - dynamic fileMetadataSize; + Object? fileMetadataSize; bool? hasPreview; String? realPath; bool? favorite; @@ -69,7 +68,9 @@ class RecognizeFaceItemParser extends XmlResponseParser { etag: etag, lastModified: lastModified, faceDetections: faceDetections, - fileMetadataSize: fileMetadataSize, + fileMetadataSize: fileMetadataSize is Map + ? fileMetadataSize.cast() + : null, hasPreview: hasPreview, realPath: realPath, favorite: favorite, @@ -105,7 +106,8 @@ class _PropParser { : (jsonDecode(child.innerText) as List).cast(); } else if (child.matchQualifiedName("file-metadata-size", prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { - _fileMetadataSize = child.innerText; + _fileMetadataSize = + child.innerText.isEmpty ? null : jsonDecode(child.innerText); } else if (child.matchQualifiedName("has-preview", prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { _hasPreview = child.innerText == "true"; @@ -127,7 +129,7 @@ class _PropParser { String? get etag => _etag; DateTime? get lastModified => _lastModified; List? get faceDetections => _faceDetections; - dynamic get fileMetadataSize => _fileMetadataSize; + Object? get fileMetadataSize => _fileMetadataSize; bool? get hasPreview => _hasPreview; String? get realPath => _realPath; bool? get favorite => _favorite; @@ -140,7 +142,8 @@ class _PropParser { String? _etag; DateTime? _lastModified; List? _faceDetections; - dynamic _fileMetadataSize; + // size can be a map or a list if the size is not known (well...) + Object? _fileMetadataSize; bool? _hasPreview; String? _realPath; bool? _favorite; diff --git a/np_api/lib/src/recognize_api.dart b/np_api/lib/src/recognize_api.dart index 98b465c0..573b3fd5 100644 --- a/np_api/lib/src/recognize_api.dart +++ b/np_api/lib/src/recognize_api.dart @@ -6,7 +6,9 @@ class ApiRecognize { ApiRecognizeFaces faces() => ApiRecognizeFaces(this); ApiRecognizeFace face(String name) => ApiRecognizeFace(this, name); - String get _path => "remote.php/dav/recognize/$userId"; + String get _userPath => "$path/$userId"; + + static String get path => "remote.php/dav/recognize"; final Api api; final String userId; @@ -17,7 +19,7 @@ class ApiRecognizeFaces { const ApiRecognizeFaces(this.recognize); Future propfind() async { - final endpoint = "${recognize._path}/faces"; + final endpoint = "${recognize._userPath}/faces"; try { return await api.request("PROPFIND", endpoint); } catch (e) { @@ -116,7 +118,7 @@ class ApiRecognizeFace { } } - String get _path => "${recognize._path}/faces/$name"; + String get _path => "${recognize._userPath}/faces/$name"; Api get api => recognize.api; final ApiRecognize recognize; diff --git a/np_api/test/entity/recognize_face_item_parser_test.dart b/np_api/test/entity/recognize_face_item_parser_test.dart index 2af9682d..229ad9f6 100644 --- a/np_api/test/entity/recognize_face_item_parser_test.dart +++ b/np_api/test/entity/recognize_face_item_parser_test.dart @@ -1,5 +1,4 @@ import 'package:np_api/np_api.dart'; -import 'package:np_api/src/entity/recognize_face_item_parser.dart'; import 'package:test/test.dart'; void main() { @@ -7,6 +6,7 @@ void main() { group("parse", () { test("empty", _empty); test("image", _image); + test("imageWithSize", _imageWithSize); }); }); } @@ -143,7 +143,7 @@ Future _image() async { "title": "test", }, ], - fileMetadataSize: "[]", + fileMetadataSize: null, hasPreview: true, realPath: "/admin/files/test1.jpg", favorite: false, @@ -152,3 +152,44 @@ Future _image() async { ], ); } + +Future _imageWithSize() async { + const xml = """ + + + + /remote.php/dav/recognize/admin/faces/test/ + + + + + HTTP/1.1 404 Not Found + + + + /remote.php/dav/recognize/admin/faces/test/test1.jpg + + + {"width":1024,"height":768} + + HTTP/1.1 200 OK + + + +"""; + final results = await RecognizeFaceItemParser().parse(xml); + expect( + results, + [ + const RecognizeFaceItem( + href: "/remote.php/dav/recognize/admin/faces/test/"), + const RecognizeFaceItem( + href: "/remote.php/dav/recognize/admin/faces/test/test1.jpg", + fileMetadataSize: { + "width": 1024, + "height": 768, + }, + ), + ], + ); +}