diff --git a/app/lib/api/entity_converter.dart b/app/lib/api/entity_converter.dart index bcd80aed..944da29c 100644 --- a/app/lib/api/entity_converter.dart +++ b/app/lib/api/entity_converter.dart @@ -17,6 +17,7 @@ import 'package:nc_photos/entity/tagged_file.dart'; import 'package:nc_photos/object_extension.dart'; import 'package:np_api/np_api.dart' as api; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/object_util.dart'; import 'package:np_string/np_string.dart'; part 'entity_converter.g.dart'; @@ -49,32 +50,36 @@ class ApiFavoriteConverter { } class ApiFileConverter { - static File fromApi(api.File file) { - var metadata = file.customProperties?["com.nkming.nc_photos:metadata"] - ?.run((obj) => Metadata.fromJson( - jsonDecode(obj), - upgraderV1: MetadataUpgraderV1( - fileContentType: file.contentType, - logFilePath: file.href, - ), - upgraderV2: MetadataUpgraderV2( - fileContentType: file.contentType, - logFilePath: file.href, - ), - upgraderV3: MetadataUpgraderV3( - fileContentType: file.contentType, - logFilePath: file.href, - ), - )); - if (file.metadataPhotosIfd0 != null) { - final ifd0_metadata = Metadata.fromPhotosIfd0(file.metadataPhotosIfd0!); - if (metadata == null) { - metadata = ifd0_metadata; - } else { - metadata = metadata.copyWith(exif: ifd0_metadata.exif); - } + static Metadata? _metadataFromApi(api.File file) { + if (file.metadataPhotosSize != null) { + return Metadata.fromApi( + etag: file.etag, + ifd0: file.metadataPhotosIfd0, + exif: file.metadataPhotosExif, + gps: file.metadataPhotosGps, + size: file.metadataPhotosSize!, + ); + } else { + return file.customProperties?["com.nkming.nc_photos:metadata"] + ?.let((obj) => Metadata.fromJson( + jsonDecode(obj), + upgraderV1: MetadataUpgraderV1( + fileContentType: file.contentType, + logFilePath: file.href, + ), + upgraderV2: MetadataUpgraderV2( + fileContentType: file.contentType, + logFilePath: file.href, + ), + upgraderV3: MetadataUpgraderV3( + fileContentType: file.contentType, + logFilePath: file.href, + ), + )); } + } + static File fromApi(api.File file) { return File( path: _hrefToPath(file.href), contentLength: file.contentLength, @@ -90,7 +95,7 @@ class ApiFileConverter { trashbinFilename: file.trashbinFilename, trashbinOriginalLocation: file.trashbinOriginalLocation, trashbinDeletionTime: file.trashbinDeletionTime, - metadata: metadata, + metadata: _metadataFromApi(file), isArchived: file.customProperties?["com.nkming.nc_photos:is-archived"] ?.run((obj) => obj == "true"), overrideDateTime: file diff --git a/app/lib/entity/exif_extension.dart b/app/lib/entity/exif_util.dart similarity index 55% rename from app/lib/entity/exif_extension.dart rename to app/lib/entity/exif_util.dart index f92bf7ac..9f1b0b4e 100644 --- a/app/lib/entity/exif_extension.dart +++ b/app/lib/entity/exif_util.dart @@ -1,4 +1,5 @@ import 'package:exifdart/exifdart.dart'; +import 'package:flutter/foundation.dart'; import 'package:nc_photos/entity/exif.dart'; extension ExifExtension on Exif { @@ -12,7 +13,7 @@ extension ExifExtension on Exif { // invalid value return null; } else { - return _gpsDmsToDouble(gpsLatitude!) * (gpsLatitudeRef == "S" ? -1 : 1); + return gpsDmsToDouble(gpsLatitude!) * (gpsLatitudeRef == "S" ? -1 : 1); } } @@ -26,12 +27,23 @@ extension ExifExtension on Exif { // invalid value return null; } else { - return _gpsDmsToDouble(gpsLongitude!) * (gpsLongitudeRef == "W" ? -1 : 1); + return gpsDmsToDouble(gpsLongitude!) * (gpsLongitudeRef == "W" ? -1 : 1); } } } -double _gpsDmsToDouble(List dms) { +List gpsDoubleToDms(double src) { + var tmp = src.abs(); + final d = tmp.floor(); + tmp -= d; + final ss = (tmp * 3600 * 100).floor(); + final s = ss % (60 * 100); + final m = (ss / (60 * 100)).floor(); + return [Rational(d, 1), Rational(m, 1), Rational(s, 100)]; +} + +@visibleForTesting +double gpsDmsToDouble(List dms) { double product = dms[0].toDouble(); if (dms.length > 1) { product += dms[1].toDouble() / 60; @@ -41,3 +53,14 @@ double _gpsDmsToDouble(List dms) { } return product; } + +Rational doubleToRational(double src) { + final s = src.abs(); + if (s < 1000) { + return Rational((s * 100000).truncate(), 100000); + } else if (s < 100000) { + return Rational((s * 1000).truncate(), 1000); + } else { + return Rational(s.truncate(), 1); + } +} diff --git a/app/lib/entity/file.dart b/app/lib/entity/file.dart index f88834db..ded0b3b9 100644 --- a/app/lib/entity/file.dart +++ b/app/lib/entity/file.dart @@ -5,10 +5,12 @@ import 'package:equatable/equatable.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/exif.dart'; +import 'package:nc_photos/entity/exif_util.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/json_util.dart' as json_util; import 'package:np_codegen/np_codegen.dart'; +import 'package:np_common/object_util.dart'; import 'package:np_common/or_null.dart'; import 'package:np_common/type.dart'; import 'package:np_string/np_string.dart'; @@ -185,13 +187,40 @@ class Metadata with EquatableMixin { ); } - static Metadata fromPhotosIfd0(Map metadataPhotosIfd0) { + static Metadata? fromApi({ + required String? etag, + Map? ifd0, + Map? exif, + Map? gps, + required Map size, + }) { + final lat = gps?["latitude"]?.let(double.tryParse); + final lng = gps?["longitude"]?.let(double.tryParse); + final alt = gps?["altitude"]?.let(double.tryParse); return Metadata( - lastUpdated: null, - fileEtag: null, - imageWidth: null, - imageHeight: null, - exif: new Exif(metadataPhotosIfd0), + lastUpdated: clock.now().toUtc(), + fileEtag: etag, + imageWidth: int.parse(size["width"]!), + imageHeight: int.parse(size["height"]!), + exif: ifd0 != null || exif != null || gps != null + ? Exif({ + if (ifd0 != null) ...ifd0, + if (exif != null) ...exif, + if (lat != null && lng != null) ...{ + "GPSLatitude": gpsDoubleToDms(lat), + "GPSLatitudeRef": lat.isNegative ? "S" : "N", + "GPSLongitude": gpsDoubleToDms(lng), + "GPSLongitudeRef": lng.isNegative ? "W" : "E", + if (alt != null) ...{ + "GPSAltitude": doubleToRational(alt), + "GPSAltitudeRef": alt.isNegative ? 1 : 0, + } + } + }..removeWhere((key, value) => + key == "MakerNote" || + key == "UserComment" || + key == "ImageDescription")) + : null, ); } diff --git a/app/lib/entity/file/data_source.dart b/app/lib/entity/file/data_source.dart index 6c87f137..08df13e6 100644 --- a/app/lib/entity/file/data_source.dart +++ b/app/lib/entity/file/data_source.dart @@ -56,6 +56,9 @@ class FileWebdavDataSource implements FileDataSource { trashbinOriginalLocation: 1, trashbinDeletionTime: 1, metadataPhotosIfd0: 1, + metadataPhotosExif: 1, + metadataPhotosGps: 1, + metadataPhotosSize: 1, customNamespaces: { "com.nkming.nc_photos": "app", }, @@ -277,6 +280,9 @@ class FileWebdavDataSource implements FileDataSource { trashbinOriginalLocation, trashbinDeletionTime, metadataPhotosIfd0, + metadataPhotosExif, + metadataPhotosGps, + metadataPhotosSize, Map? customNamespaces, List? customProperties, }) async { @@ -305,6 +311,9 @@ class FileWebdavDataSource implements FileDataSource { trashbinOriginalLocation: trashbinOriginalLocation, trashbinDeletionTime: trashbinDeletionTime, metadataPhotosIfd0: metadataPhotosIfd0, + metadataPhotosExif: metadataPhotosExif, + metadataPhotosGps: metadataPhotosGps, + metadataPhotosSize: metadataPhotosSize, customNamespaces: customNamespaces, customProperties: customProperties, ); diff --git a/app/lib/use_case/update_missing_metadata.dart b/app/lib/use_case/update_missing_metadata.dart index 8f494ff3..89a52470 100644 --- a/app/lib/use_case/update_missing_metadata.dart +++ b/app/lib/use_case/update_missing_metadata.dart @@ -5,7 +5,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/connectivity_util.dart' as connectivity_util; import 'package:nc_photos/di_container.dart'; -import 'package:nc_photos/entity/exif_extension.dart'; +import 'package:nc_photos/entity/exif_util.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/exception.dart'; diff --git a/app/lib/widget/viewer_detail_pane.dart b/app/lib/widget/viewer_detail_pane.dart index 1aa4e5b1..e0e16f52 100644 --- a/app/lib/widget/viewer_detail_pane.dart +++ b/app/lib/widget/viewer_detail_pane.dart @@ -14,7 +14,7 @@ import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/collection.dart'; import 'package:nc_photos/entity/collection/adapter.dart'; import 'package:nc_photos/entity/collection_item.dart'; -import 'package:nc_photos/entity/exif_extension.dart'; +import 'package:nc_photos/entity/exif_util.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; diff --git a/app/test/entity/exif_util_test.dart b/app/test/entity/exif_util_test.dart new file mode 100644 index 00000000..bb970479 --- /dev/null +++ b/app/test/entity/exif_util_test.dart @@ -0,0 +1,110 @@ +import 'package:exifdart/exifdart.dart'; +import 'package:nc_photos/entity/exif_util.dart'; +import 'package:test/test.dart'; + +void main() { + group("exif_util", () { + group("gpsDmsToDouble", () { + test("United Nations HQ", () { + // 40° 44′ 58″ N, 73° 58′ 5″ W + final lat = gpsDmsToDouble([ + Rational(40, 1), + Rational(44, 1), + Rational(58, 1), + ]); + final lng = gpsDmsToDouble([ + Rational(73, 1), + Rational(58, 1), + Rational(5, 1), + ]); + expect(lat, closeTo(40.749444, .00001)); + expect(lng, closeTo(73.968056, .00001)); + }); + + test("East Cape Lighthouse", () { + // 37° 41′ 20.2″ S, 178° 32′ 53.3″ E + final lat = gpsDmsToDouble([ + Rational(37, 1), + Rational(41, 1), + Rational(202, 10), + ]); + final lng = gpsDmsToDouble([ + Rational(178, 1), + Rational(32, 1), + Rational(533, 10), + ]); + expect(lat, closeTo(37.688944, .00001)); + expect(lng, closeTo(178.548139, .00001)); + }); + }); + + group("gpsDoubleToDms", () { + test("United Nations HQ", () { + // 40.749444, -73.968056 + final lat = gpsDoubleToDms(40.749444); + final lng = gpsDoubleToDms(-73.968056); + expect( + lat.map((e) => e.toString()), + [ + Rational(40, 1).toString(), + Rational(44, 1).toString(), + Rational(5799, 100).toString(), + ], + ); + expect( + lng.map((e) => e.toString()), + [ + Rational(73, 1).toString(), + Rational(58, 1).toString(), + Rational(500, 100).toString(), + ], + ); + }); + + test("East Cape Lighthouse", () { + // -37.688944, 178.548139 + final lat = gpsDoubleToDms(-37.688944); + final lng = gpsDoubleToDms(178.548139); + expect( + lat.map((e) => e.toString()), + [ + Rational(37, 1).toString(), + Rational(41, 1).toString(), + Rational(2019, 100).toString(), + ], + ); + expect( + lng.map((e) => e.toString()), + [ + Rational(178, 1).toString(), + Rational(32, 1).toString(), + Rational(5330, 100).toString(), + ], + ); + }); + }); + + group("doubleToRational", () { + test("<1000", () { + expect( + doubleToRational(123.456789123).toString(), + Rational(12345678, 100000).toString(), + ); + }); + + test(">1000 <100000", () { + expect( + doubleToRational(12345.6789123).toString(), + Rational(12345678, 1000).toString(), + ); + }); + + test(">100000", () { + expect( + doubleToRational(12345678.9123).toString(), + Rational(12345678, 1).toString(), + ); + }); + }); + }); +} diff --git a/app/test/entity/file_test.dart b/app/test/entity/file_test.dart index a91311c8..a60bb2d0 100644 --- a/app/test/entity/file_test.dart +++ b/app/test/entity/file_test.dart @@ -1,4 +1,6 @@ import 'package:clock/clock.dart'; +import 'package:exifdart/exifdart.dart' hide Metadata; +import 'package:flutter/foundation.dart'; import 'package:nc_photos/entity/exif.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; @@ -245,6 +247,14 @@ void main() { }); }); }); + + group("fromApi", () { + test("size", _fromApiSize); + group("gps", () { + test("place1", _fromApiGpsPlace1); + test("place2", _fromApiGpsPlace2); + }); + }); }); group("MetadataUpgraderV1", () { @@ -1217,3 +1227,116 @@ void main() { }); }); } + +void _fromApiSize() { + withClock( + Clock(() => DateTime(2020, 1, 2, 3, 4, 5)), + () { + final actual = Metadata.fromApi( + etag: null, + size: { + "width": "1234", + "height": "5678", + }, + ); + expect( + actual, + Metadata( + imageWidth: 1234, + imageHeight: 5678, + ), + ); + }, + ); +} + +void _fromApiGpsPlace1() { + final actual = Metadata.fromApi( + etag: null, + size: { + "width": "1234", + "height": "5678", + }, + gps: { + "latitude": "40.749444", + "longitude": "-73.968056", + "altitude": "12.345678", + }, + ); + expect( + actual?.exif, + _MetadataGpsMatcher(Exif({ + "GPSLatitude": [Rational(40, 1), Rational(44, 1), Rational(5799, 100)], + "GPSLatitudeRef": "N", + "GPSLongitude": [Rational(73, 1), Rational(58, 1), Rational(500, 100)], + "GPSLongitudeRef": "W", + "GPSAltitude": Rational(1234567, 100000), + "GPSAltitudeRef": 0, + })), + ); +} + +void _fromApiGpsPlace2() { + final actual = Metadata.fromApi( + etag: null, + size: { + "width": "1234", + "height": "5678", + }, + gps: { + "latitude": "-37.688944", + "longitude": "178.5481396", + "altitude": "-12.345678", + }, + ); + expect( + actual?.exif, + _MetadataGpsMatcher(Exif({ + "GPSLatitude": [Rational(37, 1), Rational(41, 1), Rational(2019, 100)], + "GPSLatitudeRef": "S", + "GPSLongitude": [Rational(178, 1), Rational(32, 1), Rational(5330, 100)], + "GPSLongitudeRef": "E", + "GPSAltitude": Rational(1234567, 100000), + "GPSAltitudeRef": 1, + })), + ); +} + +class _MetadataGpsMatcher extends Matcher { + const _MetadataGpsMatcher(this.expected); + + @override + bool matches(Object? item, Map matchState) { + final actual = item as Exif; + final gpsLatitude = listEquals( + actual["GPSLatitude"]?.map((e) => e.toString()).toList(), + expected["GPSLatitude"]?.map((e) => e.toString()).toList(), + ); + final gpsLatitudeRef = + actual["GPSLatitudeRef"] == expected["GPSLatitudeRef"]; + final gpsLongitude = listEquals( + actual["GPSLongitude"]?.map((e) => e.toString()).toList(), + expected["GPSLongitude"]?.map((e) => e.toString()).toList(), + ); + final gpsLongitudeRef = + actual["GPSLongitudeRef"] == expected["GPSLongitudeRef"]; + final gpsAltitude = actual["GPSAltitude"]?.toString() == + expected["GPSAltitude"]?.toString(); + final gpsAltitudeRef = + actual["GPSAltitudeRef"] == expected["GPSAltitudeRef"]; + + return gpsLatitude && + gpsLatitudeRef && + gpsLongitude && + gpsLongitudeRef && + gpsAltitude && + gpsAltitudeRef; + } + + @override + Description describe(Description description) { + return description.add(expected.toString()); + } + + final Exif expected; +}