From c54b178e5ae27d0139fdb6f49b494560d854945b Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 15 Jun 2023 23:31:22 +0800 Subject: [PATCH] Add api to retrieve face data from the Recognize app --- np_api/lib/src/api.dart | 1 + np_api/lib/src/api.g.dart | 14 ++ np_api/lib/src/entity/entity.dart | 65 ++++++++ np_api/lib/src/entity/entity.g.dart | 14 ++ .../entity/recognize_face_item_parser.dart | 154 ++++++++++++++++++ .../lib/src/entity/recognize_face_parser.dart | 35 ++++ np_api/lib/src/recognize_api.dart | 124 ++++++++++++++ .../recognize_face_item_parser_test.dart | 154 ++++++++++++++++++ .../entity/recognize_face_parser_test.dart | 115 +++++++++++++ 9 files changed, 676 insertions(+) create mode 100644 np_api/lib/src/entity/recognize_face_item_parser.dart create mode 100644 np_api/lib/src/entity/recognize_face_parser.dart create mode 100644 np_api/lib/src/recognize_api.dart create mode 100644 np_api/test/entity/recognize_face_item_parser_test.dart create mode 100644 np_api/test/entity/recognize_face_parser_test.dart diff --git a/np_api/lib/src/api.dart b/np_api/lib/src/api.dart index fac28af3..74fb6e30 100644 --- a/np_api/lib/src/api.dart +++ b/np_api/lib/src/api.dart @@ -12,6 +12,7 @@ part 'face_recognition_api.dart'; part 'files_api.dart'; part 'files_sharing_api.dart'; part 'photos_api.dart'; +part 'recognize_api.dart'; part 'status_api.dart'; part 'systemtag_api.dart'; diff --git a/np_api/lib/src/api.g.dart b/np_api/lib/src/api.g.dart index 5f649a80..f002c210 100644 --- a/np_api/lib/src/api.g.dart +++ b/np_api/lib/src/api.g.dart @@ -77,6 +77,20 @@ extension _$ApiPhotosAlbumNpLog on ApiPhotosAlbum { static final log = Logger("src.api.ApiPhotosAlbum"); } +extension _$ApiRecognizeFacesNpLog on ApiRecognizeFaces { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("src.api.ApiRecognizeFaces"); +} + +extension _$ApiRecognizeFaceNpLog on ApiRecognizeFace { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("src.api.ApiRecognizeFace"); +} + extension _$ApiStatusNpLog on ApiStatus { // ignore: unused_element Logger get _log => log; diff --git a/np_api/lib/src/entity/entity.dart b/np_api/lib/src/entity/entity.dart index cfa59726..2c4cb6bc 100644 --- a/np_api/lib/src/entity/entity.dart +++ b/np_api/lib/src/entity/entity.dart @@ -212,6 +212,71 @@ class Person with EquatableMixin { final int count; } +@toString +class RecognizeFace with EquatableMixin { + const RecognizeFace({ + required this.href, + }); + + @override + String toString() => _$toString(); + + @override + List get props => [ + href, + ]; + + final String href; +} + +@ToString(ignoreNull: true) +class RecognizeFaceItem with EquatableMixin { + const RecognizeFaceItem({ + required this.href, + this.contentLength, + this.contentType, + this.etag, + this.lastModified, + this.faceDetections, + this.fileMetadataSize, + this.hasPreview, + this.realPath, + this.favorite, + this.fileId, + }); + + @override + String toString() => _$toString(); + + @override + List get props => [ + href, + contentLength, + contentType, + etag, + lastModified, + faceDetections, + fileMetadataSize, + hasPreview, + realPath, + favorite, + fileId, + ]; + + final String href; + final int? contentLength; + final String? contentType; + final String? etag; + final DateTime? lastModified; + final List? faceDetections; + // format currently unknown + final dynamic fileMetadataSize; + final bool? hasPreview; + final String? realPath; + final bool? favorite; + final int? fileId; +} + @toString class Share with EquatableMixin { const Share({ diff --git a/np_api/lib/src/entity/entity.g.dart b/np_api/lib/src/entity/entity.g.dart index 4921aba4..abd5ee13 100644 --- a/np_api/lib/src/entity/entity.g.dart +++ b/np_api/lib/src/entity/entity.g.dart @@ -55,6 +55,20 @@ extension _$PersonToString on Person { } } +extension _$RecognizeFaceToString on RecognizeFace { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "RecognizeFace {href: $href}"; + } +} + +extension _$RecognizeFaceItemToString on RecognizeFaceItem { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "RecognizeFaceItem {href: $href, ${contentLength == null ? "" : "contentLength: $contentLength, "}${contentType == null ? "" : "contentType: $contentType, "}${etag == null ? "" : "etag: $etag, "}${lastModified == null ? "" : "lastModified: $lastModified, "}${faceDetections == null ? "" : "faceDetections: $faceDetections, "}fileMetadataSize: $fileMetadataSize, ${hasPreview == null ? "" : "hasPreview: $hasPreview, "}${realPath == null ? "" : "realPath: $realPath, "}${favorite == null ? "" : "favorite: $favorite, "}${fileId == null ? "" : "fileId: $fileId"}}"; + } +} + extension _$ShareToString on Share { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/np_api/lib/src/entity/recognize_face_item_parser.dart b/np_api/lib/src/entity/recognize_face_item_parser.dart new file mode 100644 index 00000000..4cb303a1 --- /dev/null +++ b/np_api/lib/src/entity/recognize_face_item_parser.dart @@ -0,0 +1,154 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:np_api/src/entity/entity.dart'; +import 'package:np_api/src/entity/parser.dart'; +import 'package:np_common/log.dart'; +import 'package:np_common/type.dart'; +import 'package:xml/xml.dart'; + +class RecognizeFaceItemParser extends XmlResponseParser { + Future> parse(String response) => + compute(_parseRecognizeFaceItemsIsolate, response); + + List _parse(XmlDocument xml) => + parseT(xml, _toRecognizeFaceItem); + + /// Map contents to RecognizeFaceItem + RecognizeFaceItem _toRecognizeFaceItem(XmlElement element) { + String? href; + int? contentLength; + String? contentType; + String? etag; + DateTime? lastModified; + List? faceDetections; + // format currently unknown + dynamic fileMetadataSize; + bool? hasPreview; + String? realPath; + bool? favorite; + int? fileId; + + for (final child in element.children.whereType()) { + if (child.matchQualifiedName("href", + prefix: "DAV:", namespaces: namespaces)) { + href = Uri.decodeComponent(child.innerText); + } else if (child.matchQualifiedName("propstat", + prefix: "DAV:", namespaces: namespaces)) { + final status = child.children + .whereType() + .firstWhere((element) => element.matchQualifiedName("status", + prefix: "DAV:", namespaces: namespaces)) + .innerText; + if (!status.contains(" 200 ")) { + continue; + } + final prop = child.children.whereType().firstWhere( + (element) => element.matchQualifiedName("prop", + prefix: "DAV:", namespaces: namespaces)); + final propParser = _PropParser(namespaces: namespaces); + propParser.parse(prop); + contentLength = propParser.contentLength; + contentType = propParser.contentType; + etag = propParser.etag; + lastModified = propParser.lastModified; + faceDetections = propParser.faceDetections; + fileMetadataSize = propParser.fileMetadataSize; + hasPreview = propParser.hasPreview; + realPath = propParser.realPath; + favorite = propParser.favorite; + fileId = propParser.fileId; + } + } + + return RecognizeFaceItem( + href: href!, + contentLength: contentLength, + contentType: contentType, + etag: etag, + lastModified: lastModified, + faceDetections: faceDetections, + fileMetadataSize: fileMetadataSize, + hasPreview: hasPreview, + realPath: realPath, + favorite: favorite, + fileId: fileId, + ); + } +} + +class _PropParser { + _PropParser({ + this.namespaces = const {}, + }); + + /// Parse element contents + void parse(XmlElement element) { + for (final child in element.children.whereType()) { + if (child.matchQualifiedName("getcontentlength", + prefix: "DAV:", namespaces: namespaces)) { + _contentLength = int.parse(child.innerText); + } else if (child.matchQualifiedName("getcontenttype", + prefix: "DAV:", namespaces: namespaces)) { + _contentType = child.innerText; + } else if (child.matchQualifiedName("getetag", + prefix: "DAV:", namespaces: namespaces)) { + _etag = child.innerText.replaceAll("\"", ""); + } else if (child.matchQualifiedName("getlastmodified", + prefix: "DAV:", namespaces: namespaces)) { + _lastModified = HttpDate.parse(child.innerText); + } else if (child.matchQualifiedName("face-detections", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + _faceDetections = child.innerText.isEmpty + ? null + : (jsonDecode(child.innerText) as List).cast(); + } else if (child.matchQualifiedName("file-metadata-size", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + _fileMetadataSize = child.innerText; + } else if (child.matchQualifiedName("has-preview", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + _hasPreview = child.innerText == "true"; + } else if (child.matchQualifiedName("realpath", + prefix: "http://nextcloud.org/ns", namespaces: namespaces)) { + _realPath = child.innerText; + } else if (child.matchQualifiedName("favorite", + prefix: "http://owncloud.org/ns", namespaces: namespaces)) { + _favorite = child.innerText != "0"; + } else if (child.matchQualifiedName("fileid", + prefix: "http://owncloud.org/ns", namespaces: namespaces)) { + _fileId = int.parse(child.innerText); + } + } + } + + int? get contentLength => _contentLength; + String? get contentType => _contentType; + String? get etag => _etag; + DateTime? get lastModified => _lastModified; + List? get faceDetections => _faceDetections; + dynamic get fileMetadataSize => _fileMetadataSize; + bool? get hasPreview => _hasPreview; + String? get realPath => _realPath; + bool? get favorite => _favorite; + int? get fileId => _fileId; + + final Map namespaces; + + int? _contentLength; + String? _contentType; + String? _etag; + DateTime? _lastModified; + List? _faceDetections; + dynamic _fileMetadataSize; + bool? _hasPreview; + String? _realPath; + bool? _favorite; + int? _fileId; +} + +List _parseRecognizeFaceItemsIsolate(String response) { + initLog(); + final xml = XmlDocument.parse(response); + return RecognizeFaceItemParser()._parse(xml); +} diff --git a/np_api/lib/src/entity/recognize_face_parser.dart b/np_api/lib/src/entity/recognize_face_parser.dart new file mode 100644 index 00000000..de4c198c --- /dev/null +++ b/np_api/lib/src/entity/recognize_face_parser.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart'; +import 'package:np_api/src/entity/entity.dart'; +import 'package:np_api/src/entity/parser.dart'; +import 'package:np_common/log.dart'; +import 'package:xml/xml.dart'; + +class RecognizeFaceParser extends XmlResponseParser { + Future> parse(String response) => + compute(_parseRecognizeFacesIsolate, response); + + List _parse(XmlDocument xml) => + parseT(xml, _toRecognizeFace); + + /// Map contents to RecognizeFace + RecognizeFace _toRecognizeFace(XmlElement element) { + String? href; + + for (final child in element.children.whereType()) { + if (child.matchQualifiedName("href", + prefix: "DAV:", namespaces: namespaces)) { + href = Uri.decodeComponent(child.innerText); + } + } + + return RecognizeFace( + href: href!, + ); + } +} + +List _parseRecognizeFacesIsolate(String response) { + initLog(); + final xml = XmlDocument.parse(response); + return RecognizeFaceParser()._parse(xml); +} diff --git a/np_api/lib/src/recognize_api.dart b/np_api/lib/src/recognize_api.dart new file mode 100644 index 00000000..98b465c0 --- /dev/null +++ b/np_api/lib/src/recognize_api.dart @@ -0,0 +1,124 @@ +part of 'api.dart'; + +class ApiRecognize { + const ApiRecognize(this.api, this.userId); + + ApiRecognizeFaces faces() => ApiRecognizeFaces(this); + ApiRecognizeFace face(String name) => ApiRecognizeFace(this, name); + + String get _path => "remote.php/dav/recognize/$userId"; + + final Api api; + final String userId; +} + +@npLog +class ApiRecognizeFaces { + const ApiRecognizeFaces(this.recognize); + + Future propfind() async { + final endpoint = "${recognize._path}/faces"; + try { + return await api.request("PROPFIND", endpoint); + } catch (e) { + _log.severe("[propfind] Failed while propfind", e); + rethrow; + } + } + + Api get api => recognize.api; + final ApiRecognize recognize; +} + +@npLog +class ApiRecognizeFace { + const ApiRecognizeFace(this.recognize, this.name); + + Future propfind({ + getcontentlength, + getcontenttype, + getetag, + getlastmodified, + faceDetections, + fileMetadataSize, + hasPreview, + realpath, + favorite, + fileid, + }) async { + final endpoint = _path; + try { + final bool hasDavNs = (getcontentlength != null || + getcontenttype != null || + getetag != null || + getlastmodified != null); + final bool hasNcNs = (faceDetections != null || + fileMetadataSize != null || + hasPreview != null || + realpath != null); + final bool hasOcNs = (favorite != null || fileid != null); + if (!hasDavNs && !hasOcNs && !hasNcNs) { + // no body + return await api.request("PROPFIND", endpoint); + } + + final namespaces = { + "DAV:": "d", + if (hasOcNs) "http://owncloud.org/ns": "oc", + if (hasNcNs) "http://nextcloud.org/ns": "nc", + }; + final builder = XmlBuilder(); + builder + ..processing("xml", "version=\"1.0\"") + ..element("d:propfind", namespaces: namespaces, nest: () { + builder.element("d:prop", nest: () { + if (getcontentlength != null) { + builder.element("d:getcontentlength"); + } + if (getcontenttype != null) { + builder.element("d:getcontenttype"); + } + if (getetag != null) { + builder.element("d:getetag"); + } + if (getlastmodified != null) { + builder.element("d:getlastmodified"); + } + if (faceDetections != null) { + builder.element("nc:face-detections"); + } + if (fileMetadataSize != null) { + builder.element("nc:file-metadata-size"); + } + if (hasPreview != null) { + builder.element("nc:has-preview"); + } + if (realpath != null) { + builder.element("nc:realpath"); + } + if (favorite != null) { + builder.element("oc:favorite"); + } + if (fileid != null) { + builder.element("oc:fileid"); + } + }); + }); + return await api.request( + "PROPFIND", + endpoint, + header: {"Content-Type": "application/xml"}, + body: builder.buildDocument().toXmlString(), + ); + } catch (e) { + _log.severe("[propfind] Failed while propfind", e); + rethrow; + } + } + + String get _path => "${recognize._path}/faces/$name"; + + Api get api => recognize.api; + final ApiRecognize recognize; + final String name; +} diff --git a/np_api/test/entity/recognize_face_item_parser_test.dart b/np_api/test/entity/recognize_face_item_parser_test.dart new file mode 100644 index 00000000..2af9682d --- /dev/null +++ b/np_api/test/entity/recognize_face_item_parser_test.dart @@ -0,0 +1,154 @@ +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() { + group("RecognizeFaceItemParser", () { + group("parse", () { + test("empty", _empty); + test("image", _image); + }); + }); +} + +Future _empty() async { + const xml = """ + + + + /remote.php/dav/recognize/admin/faces/test/ + + + + + + + HTTP/1.1 200 OK + + + + + + + + + + + + + + + + HTTP/1.1 404 Not Found + + + +"""; + final results = await RecognizeFaceItemParser().parse(xml); + expect( + results, + const [ + RecognizeFaceItem(href: "/remote.php/dav/recognize/admin/faces/test/"), + ], + ); +} + +Future _image() async { + const xml = """ + + + + /remote.php/dav/recognize/admin/faces/test/ + + + + + + + HTTP/1.1 200 OK + + + + + + + + + + + + + + + + HTTP/1.1 404 Not Found + + + + /remote.php/dav/recognize/admin/faces/test/test1.jpg + + + 12345 + image/jpeg + 00000000000000000000000000000000 + Sun, 1 Jan 2023 01:02:03 GMT + + [{"id":1,"userId":"test","fileId":2,"x":0.5,"y":0.5,"height":0.1,"width":0.1,"vector":[-0.1,0.1,0.1,-0.01],"clusterId":10,"title":"test"}] + [] + true + /admin/files/test1.jpg + 0 + 2 + + HTTP/1.1 200 OK + + + + + + HTTP/1.1 404 Not Found + + + +"""; + final results = await RecognizeFaceItemParser().parse(xml); + expect( + results, + [ + const RecognizeFaceItem( + href: "/remote.php/dav/recognize/admin/faces/test/"), + RecognizeFaceItem( + href: "/remote.php/dav/recognize/admin/faces/test/test1.jpg", + contentLength: 12345, + contentType: "image/jpeg", + etag: "00000000000000000000000000000000", + lastModified: DateTime.utc(2023, 1, 1, 1, 2, 3), + faceDetections: [ + { + "id": 1, + "userId": "test", + "fileId": 2, + "x": 0.5, + "y": 0.5, + "height": 0.1, + "width": 0.1, + "vector": [-0.1, 0.1, 0.1, -0.01], + "clusterId": 10, + "title": "test", + }, + ], + fileMetadataSize: "[]", + hasPreview: true, + realPath: "/admin/files/test1.jpg", + favorite: false, + fileId: 2, + ), + ], + ); +} diff --git a/np_api/test/entity/recognize_face_parser_test.dart b/np_api/test/entity/recognize_face_parser_test.dart new file mode 100644 index 00000000..cbe40f7a --- /dev/null +++ b/np_api/test/entity/recognize_face_parser_test.dart @@ -0,0 +1,115 @@ +import 'package:np_api/np_api.dart'; +import 'package:np_api/src/entity/recognize_face_parser.dart'; +import 'package:test/test.dart'; + +void main() { + group("RecognizeFaceParser", () { + group("parse", () { + test("empty", _empty); + test("unnamed", _unnamed); + test("named", _named); + }); + }); +} + +Future _empty() async { + const xml = """ + + + + /remote.php/dav/recognize/admin/faces/ + + + + + + + HTTP/1.1 200 OK + + + +"""; + final results = await RecognizeFaceParser().parse(xml); + expect( + results, + const [ + RecognizeFace(href: "/remote.php/dav/recognize/admin/faces/"), + ], + ); +} + +Future _unnamed() async { + const xml = """ + + + + /remote.php/dav/recognize/admin/faces/ + + + + + + + HTTP/1.1 200 OK + + + + /remote.php/dav/recognize/admin/faces/10/ + + + + + + + HTTP/1.1 200 OK + + + +"""; + final results = await RecognizeFaceParser().parse(xml); + expect( + results, + const [ + RecognizeFace(href: "/remote.php/dav/recognize/admin/faces/"), + RecognizeFace(href: "/remote.php/dav/recognize/admin/faces/10/"), + ], + ); +} + +Future _named() async { + const xml = """ + + + + /remote.php/dav/recognize/admin/faces/ + + + + + + + HTTP/1.1 200 OK + + + + /remote.php/dav/recognize/admin/faces/lovely%20face/ + + + + + + + HTTP/1.1 200 OK + + + +"""; + final results = await RecognizeFaceParser().parse(xml); + expect( + results, + const [ + RecognizeFace(href: "/remote.php/dav/recognize/admin/faces/"), + RecognizeFace(href: "/remote.php/dav/recognize/admin/faces/lovely face/"), + ], + ); +}