diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt index 9040083d5..adc3449f5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt @@ -20,8 +20,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) } "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) } "editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) } - "setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) } - "setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) } + "editMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editMetadata) } "removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) } else -> result.notImplemented() } @@ -99,12 +98,11 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { }) } - private fun setIptc(call: MethodCall, result: MethodChannel.Result) { - val iptc = call.argument>("iptc") + private fun editMetadata(call: MethodCall, result: MethodChannel.Result) { + val metadata = call.argument("metadata") val entryMap = call.argument("entry") - val postEditScan = call.argument("postEditScan") - if (entryMap == null || postEditScan == null) { - result.error("setIptc-args", "failed because of missing arguments", null) + if (entryMap == null || metadata == null) { + result.error("editMetadata-args", "failed because of missing arguments", null) return } @@ -112,48 +110,19 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { val path = entryMap["path"] as String? val mimeType = entryMap["mimeType"] as String? if (uri == null || path == null || mimeType == null) { - result.error("setIptc-args", "failed because entry fields are missing", null) + result.error("editMetadata-args", "failed because entry fields are missing", null) return } val provider = getProvider(uri) if (provider == null) { - result.error("setIptc-provider", "failed to find provider for uri=$uri", null) + result.error("editMetadata-provider", "failed to find provider for uri=$uri", null) return } - provider.setIptc(activity, path, uri, mimeType, postEditScan, iptc = iptc, callback = object : ImageOpCallback { + provider.editMetadata(activity, path, uri, mimeType, metadata, callback = object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("setIptc-failure", "failed to set IPTC for mimeType=$mimeType uri=$uri", throwable.message) - }) - } - - private fun setXmp(call: MethodCall, result: MethodChannel.Result) { - val xmp = call.argument("xmp") - val extendedXmp = call.argument("extendedXmp") - val entryMap = call.argument("entry") - if (entryMap == null) { - result.error("setXmp-args", "failed because of missing arguments", null) - return - } - - val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } - val path = entryMap["path"] as String? - val mimeType = entryMap["mimeType"] as String? - if (uri == null || path == null || mimeType == null) { - result.error("setXmp-args", "failed because entry fields are missing", null) - return - } - - val provider = getProvider(uri) - if (provider == null) { - result.error("setXmp-provider", "failed to find provider for uri=$uri", null) - return - } - - provider.setXmp(activity, path, uri, mimeType, coreXmp = xmp, extendedXmp = extendedXmp, callback = object : ImageOpCallback { - override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("setXmp-failure", "failed to set XMP for mimeType=$mimeType uri=$uri", throwable.message) + override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable.message) }) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index d0fcdd67c..b461e2b9f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -118,8 +118,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) - foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) - foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) + foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 } + foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 } val uuidDirCount = HashMap() val dirByName = metadata.directories.filter { it.tagCount > 0 @@ -358,26 +358,22 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { return dirMap } - // legend: ME=MetadataExtractor, EI=ExifInterface, MMR=MediaMetadataRetriever // set `KEY_DATE_MILLIS` from these fields (by precedence): - // - ME / Exif / DATETIME_ORIGINAL - // - ME / Exif / DATETIME - // - EI / Exif / DATETIME_ORIGINAL - // - EI / Exif / DATETIME - // - ME / XMP / xmp:CreateDate - // - ME / XMP / photoshop:DateCreated - // - ME / PNG / TIME / LAST_MODIFICATION_TIME - // - MMR / METADATA_KEY_DATE + // - Exif / DATETIME_ORIGINAL + // - Exif / DATETIME + // - XMP / xmp:CreateDate + // - XMP / photoshop:DateCreated + // - PNG / TIME / LAST_MODIFICATION_TIME + // - Video / METADATA_KEY_DATE // set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence): - // - ME / XMP / dc:title - // - ME / XMP / dc:description + // - XMP / dc:title + // - XMP / dc:description // set `KEY_XMP_SUBJECTS` from these fields (by precedence): - // - ME / XMP / dc:subject - // - ME / IPTC / keywords + // - XMP / dc:subject + // - IPTC / keywords // set `KEY_RATING` from these fields (by precedence): - // - ME / XMP / xmp:Rating - // - ME / XMP / MicrosoftPhoto:Rating - // - ME / XMP / acdsee:rating + // - XMP / xmp:Rating + // - XMP / MicrosoftPhoto:Rating private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } @@ -412,7 +408,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) - foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) + foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 } // File type for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) { @@ -437,13 +433,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { // EXIF for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { - dir.getSafeDateMillis(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } + dir.getSafeDateMillis(ExifDirectoryBase.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } } for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { - dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it } + dir.getSafeDateMillis(ExifDirectoryBase.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it } } - dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) { + dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) { val orientation = it if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation) @@ -486,9 +482,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { val standardRating = (percentRating / 25f).roundToInt() + 1 metadataMap[KEY_RATING] = standardRating } - if (!metadataMap.containsKey(KEY_RATING)) { - xmpMeta.getSafeInt(XMP.ACDSEE_SCHEMA_NS, XMP.ACDSEE_RATING_PROP_NAME) { metadataMap[KEY_RATING] = it } - } } // identification of panorama (aka photo sphere) @@ -676,10 +669,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { val metadata = ImageMetadataReader.readMetadata(input) for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { foundExif = true - dir.getSafeRational(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator } - dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME, saveExposureTime) - dir.getSafeRational(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator } - dir.getSafeInt(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it } + dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator } + dir.getSafeRational(ExifDirectoryBase.TAG_EXPOSURE_TIME, saveExposureTime) + dir.getSafeRational(ExifDirectoryBase.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator } + dir.getSafeInt(ExifDirectoryBase.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it } } } } catch (e: Exception) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 97f099fe4..2819915e8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -14,7 +14,6 @@ object XMP { // standard namespaces // cf com.adobe.internal.xmp.XMPConst - const val ACDSEE_SCHEMA_NS = "http://ns.acdsee.com/iptc/1.0/" const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" const val MICROSOFTPHOTO_SCHEMA_NS = "http://ns.microsoft.com/photo/1.0/" const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/" @@ -29,7 +28,6 @@ object XMP { const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/" private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/" - const val ACDSEE_RATING_PROP_NAME = "acdsee:rating" const val DC_DESCRIPTION_PROP_NAME = "dc:description" const val DC_SUBJECT_PROP_NAME = "dc:subject" const val DC_TITLE_PROP_NAME = "dc:title" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 1e04ca4ac..9ef5a9112 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -800,63 +800,47 @@ abstract class ImageProvider { } } - fun setIptc( + fun editMetadata( context: Context, path: String, uri: Uri, mimeType: String, - postEditScan: Boolean, + modifier: FieldMap, callback: ImageOpCallback, - iptc: List? = null, ) { - val newFields = HashMap() + if (modifier.containsKey("iptc")) { + val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance() + if (!editIptc( + context = context, + path = path, + uri = uri, + mimeType = mimeType, + callback = callback, + iptc = iptc, + ) + ) return + } - val success = editIptc( - context = context, - path = path, - uri = uri, - mimeType = mimeType, - callback = callback, - iptc = iptc, - ) - - if (success) { - if (postEditScan) { - scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) - } else { - callback.onSuccess(HashMap()) + if (modifier.containsKey("xmp")) { + val xmp = modifier["xmp"] as Map<*, *>? + if (xmp != null) { + val coreXmp = xmp["xmp"] as String? + val extendedXmp = xmp["extendedXmp"] as String? + if (!editXmp( + context = context, + path = path, + uri = uri, + mimeType = mimeType, + callback = callback, + coreXmp = coreXmp, + extendedXmp = extendedXmp, + ) + ) return } - } else { - callback.onFailure(Exception("failed to set IPTC")) } - } - fun setXmp( - context: Context, - path: String, - uri: Uri, - mimeType: String, - callback: ImageOpCallback, - coreXmp: String? = null, - extendedXmp: String? = null, - ) { val newFields = HashMap() - - val success = editXmp( - context = context, - path = path, - uri = uri, - mimeType = mimeType, - callback = callback, - coreXmp = coreXmp, - extendedXmp = extendedXmp, - ) - - if (success) { - scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) - } else { - callback.onFailure(Exception("failed to set XMP")) - } + scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) } fun removeMetadataTypes( diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 20d207b42..edebff219 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -22,7 +22,7 @@ "nextTooltip": "Nächste", "showTooltip": "Anzeigen", "hideTooltip": "Ausblenden", - "removeTooltip": "Entfernen", + "actionRemove": "Entfernen", "resetButtonTooltip": "Zurücksetzen", "doubleBackExitMessage": "Tippen Sie zum Verlassen erneut auf „Zurück“.", @@ -180,7 +180,6 @@ "editEntryDateDialogTitle": "Datum & Uhrzeit", "editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel", "editEntryDateDialogShift": "Verschieben", - "editEntryDateDialogClear": "Aufräumen", "editEntryDateDialogHours": "Stunden", "editEntryDateDialogMinutes": "Minuten", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index aec69c717..b90b87835 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -37,7 +37,7 @@ "nextTooltip": "Next", "showTooltip": "Show", "hideTooltip": "Hide", - "removeTooltip": "Remove", + "actionRemove": "Remove", "resetButtonTooltip": "Reset", "doubleBackExitMessage": "Tap “back” again to exit.", @@ -88,6 +88,7 @@ "videoActionSettings": "Settings", "entryInfoActionEditDate": "Edit date & time", + "entryInfoActionEditRating": "Edit rating", "entryInfoActionEditTags": "Edit tags", "entryInfoActionRemoveMetadata": "Remove metadata", @@ -279,12 +280,13 @@ "editEntryDateDialogCopyField": "Set from other date", "editEntryDateDialogExtractFromTitle": "Extract from title", "editEntryDateDialogShift": "Shift", - "editEntryDateDialogClear": "Clear", "editEntryDateDialogSourceFileModifiedDate": "File modified date", "editEntryDateDialogTargetFieldsHeader": "Fields to modify", "editEntryDateDialogHours": "Hours", "editEntryDateDialogMinutes": "Minutes", + "editEntryRatingDialogTitle": "Rating", + "removeEntryMetadataDialogTitle": "Metadata Removal", "removeEntryMetadataDialogMore": "More", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 2971c6bec..87b92dd51 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -22,7 +22,7 @@ "nextTooltip": "Suivant", "showTooltip": "Afficher", "hideTooltip": "Masquer", - "removeTooltip": "Supprimer", + "actionRemove": "Supprimer", "resetButtonTooltip": "Réinitialiser", "doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.", @@ -184,7 +184,6 @@ "editEntryDateDialogCopyField": "Copier d'une autre date", "editEntryDateDialogExtractFromTitle": "Extraire du titre", "editEntryDateDialogShift": "Décaler", - "editEntryDateDialogClear": "Effacer", "editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier", "editEntryDateDialogTargetFieldsHeader": "Champs à modifier", "editEntryDateDialogHours": "Heures", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index c5cd70c88..56955ad05 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -22,7 +22,7 @@ "nextTooltip": "다음", "showTooltip": "보기", "hideTooltip": "숨기기", - "removeTooltip": "제거", + "actionRemove": "제거", "resetButtonTooltip": "복원", "doubleBackExitMessage": "종료하려면 한번 더 누르세요.", @@ -184,7 +184,6 @@ "editEntryDateDialogCopyField": "다른 날짜에서 지정", "editEntryDateDialogExtractFromTitle": "제목에서 추출", "editEntryDateDialogShift": "시간 이동", - "editEntryDateDialogClear": "삭제", "editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜", "editEntryDateDialogTargetFieldsHeader": "수정할 필드", "editEntryDateDialogHours": "시간", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e851d2fe2..9d9e3809f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -22,7 +22,7 @@ "nextTooltip": "Следующий", "showTooltip": "Показать", "hideTooltip": "Скрыть", - "removeTooltip": "Удалить", + "actionRemove": "Удалить", "resetButtonTooltip": "Сбросить", "doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.", @@ -180,7 +180,6 @@ "editEntryDateDialogTitle": "Дата и время", "editEntryDateDialogExtractFromTitle": "Извлечь из названия", "editEntryDateDialogShift": "Сдвиг", - "editEntryDateDialogClear": "Очистить", "editEntryDateDialogHours": "Часов", "editEntryDateDialogMinutes": "Минут", diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index 901133187..94ff10857 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; enum EntryInfoAction { // general editDate, + editRating, editTags, removeMetadata, // motion photo @@ -14,6 +15,7 @@ enum EntryInfoAction { class EntryInfoActions { static const all = [ EntryInfoAction.editDate, + EntryInfoAction.editRating, EntryInfoAction.editTags, EntryInfoAction.removeMetadata, EntryInfoAction.viewMotionPhotoVideo, @@ -26,6 +28,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { // general case EntryInfoAction.editDate: return context.l10n.entryInfoActionEditDate; + case EntryInfoAction.editRating: + return context.l10n.entryInfoActionEditRating; case EntryInfoAction.editTags: return context.l10n.entryInfoActionEditTags; case EntryInfoAction.removeMetadata: @@ -45,6 +49,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { // general case EntryInfoAction.editDate: return AIcons.date; + case EntryInfoAction.editRating: + return AIcons.rating; case EntryInfoAction.editTags: return AIcons.addTag; case EntryInfoAction.removeMetadata: diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 2d97dabca..6d8ae339c 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -25,6 +25,7 @@ enum EntrySetAction { rotateCW, flip, editDate, + editRating, editTags, removeMetadata, } @@ -101,6 +102,8 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.entryActionFlip; case EntrySetAction.editDate: return context.l10n.entryInfoActionEditDate; + case EntrySetAction.editRating: + return context.l10n.entryInfoActionEditRating; case EntrySetAction.editTags: return context.l10n.entryInfoActionEditTags; case EntrySetAction.removeMetadata: @@ -155,6 +158,8 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.flip; case EntrySetAction.editDate: return AIcons.date; + case EntrySetAction.editRating: + return AIcons.rating; case EntrySetAction.editTags: return AIcons.addTag; case EntrySetAction.removeMetadata: diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 9d2e83afe..d7650828d 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -239,6 +239,8 @@ class AvesEntry { bool get canEditDate => canEdit && canEditExif; + bool get canEditRating => canEdit && canEditXmp; + bool get canEditTags => canEdit && canEditXmp; bool get canRotateAndFlip => canEdit && canEditExif; @@ -709,7 +711,7 @@ class AvesEntry { break; case DateEditAction.setCustom: case DateEditAction.shift: - case DateEditAction.clear: + case DateEditAction.remove: break; } final newFields = await metadataEditService.editDate(this, modifier); @@ -733,10 +735,14 @@ class AvesEntry { final metadataDate = catalogMetadata?.dateMillis; if (metadataDate != null && metadataDate > 0) return {}; - return await editDate(DateModifier.copyField( - const {MetadataField.exifDateOriginal}, - DateFieldSource.fileModifiedDate, - )); + if (canEditExif) { + return await editDate(DateModifier.copyField( + const {MetadataField.exifDateOriginal}, + DateFieldSource.fileModifiedDate, + )); + } + // TODO TLAD [metadata] set XMP / xmp:CreateDate + return {}; } Future> removeMetadata(Set types) async { diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart new file mode 100644 index 000000000..e9428a7b8 --- /dev/null +++ b/lib/model/entry_metadata_edition.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/ref/iptc.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/services/metadata/xmp.dart'; +import 'package:aves/utils/xmp_utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:xml/xml.dart'; + +extension ExtraAvesEntryMetadataEdition on AvesEntry { + // write: + // - IPTC / keywords, if IPTC exists + // - XMP / dc:subject + Future> editTags(Set tags) async { + final Map metadata = {}; + + final dataTypes = await setMetadataDateIfMissing(); + + if (canEditIptc) { + final iptc = await metadataFetchService.getIptc(this); + if (iptc != null) { + editTagsIptc(iptc, tags); + metadata[MetadataType.iptc] = iptc; + } + } + + if (canEditXmp) { + metadata[MetadataType.xmp] = await _editXmp((descriptions) => editTagsXmp(descriptions, tags)); + } + + final newFields = await metadataEditService.editMetadata(this, metadata); + if (newFields.isNotEmpty) { + dataTypes.add(EntryDataType.catalog); + } + return dataTypes; + } + + // write: + // - XMP / xmp:Rating + // update: + // - XMP / MicrosoftPhoto:Rating + // ignore (Windows tags, not part of Exif 2.32 spec): + // - Exif / Rating + // - Exif / RatingPercent + Future> editRating(int? rating) async { + final Map metadata = {}; + + final dataTypes = await setMetadataDateIfMissing(); + + if (canEditXmp) { + metadata[MetadataType.xmp] = await _editXmp((descriptions) => editRatingXmp(descriptions, rating)); + } + + final newFields = await metadataEditService.editMetadata(this, metadata); + if (newFields.isNotEmpty) { + dataTypes.add(EntryDataType.catalog); + } + return dataTypes; + } + + @visibleForTesting + static void editTagsIptc(List> iptc, Set tags) { + iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag); + iptc.add({ + 'record': IPTC.applicationRecord, + 'tag': IPTC.keywordsTag, + 'values': tags.map((v) => utf8.encode(v)).toList(), + }); + } + + @visibleForTesting + static void editTagsXmp(List descriptions, Set tags) { + XMP.setStringBag( + descriptions, + XMP.dcSubject, + tags, + namespace: Namespaces.dc, + strat: XmpEditStrategy.always, + ); + } + + @visibleForTesting + static void editRatingXmp(List descriptions, int? rating) { + XMP.setAttribute( + descriptions, + XMP.xmpRating, + (rating ?? 0) == 0 ? null : '$rating', + namespace: Namespaces.xmp, + strat: XmpEditStrategy.always, + ); + XMP.setAttribute( + descriptions, + XMP.msPhotoRating, + XMP.toMsPhotoRating(rating), + namespace: Namespaces.microsoftPhoto, + strat: XmpEditStrategy.updateIfPresent, + ); + } + + // convenience + + Future> _editXmp(void Function(List descriptions) apply) async { + final xmp = await metadataFetchService.getXmp(this); + final xmpString = xmp?.xmpString; + final extendedXmpString = xmp?.extendedXmpString; + + final editedXmpString = await XMP.edit( + xmpString, + () => PackageInfo.fromPlatform().then((v) => 'Aves v${v.version}'), + apply, + ); + + final editedXmp = AvesXmp(xmpString: editedXmpString, extendedXmpString: extendedXmpString); + return { + 'xmp': editedXmp.xmpString, + 'extendedXmp': editedXmp.extendedXmpString, + }; + } +} diff --git a/lib/model/entry_xmp_iptc.dart b/lib/model/entry_xmp_iptc.dart deleted file mode 100644 index b61c80cd7..000000000 --- a/lib/model/entry_xmp_iptc.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'dart:convert'; - -import 'package:aves/model/entry.dart'; -import 'package:aves/ref/iptc.dart'; -import 'package:aves/services/common/services.dart'; -import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; -import 'package:intl/intl.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:xml/xml.dart'; - -extension ExtraAvesEntryXmpIptc on AvesEntry { - static const dcNamespace = 'http://purl.org/dc/elements/1.1/'; - static const rdfNamespace = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; - static const xNamespace = 'adobe:ns:meta/'; - static const xmpNamespace = 'http://ns.adobe.com/xap/1.0/'; - static const xmpNoteNamespace = 'http://ns.adobe.com/xmp/note/'; - - static const xmlnsPrefix = 'xmlns'; - - static final nsDefaultPrefixes = { - dcNamespace: 'dc', - rdfNamespace: 'rdf', - xNamespace: 'x', - xmpNamespace: 'xmp', - xmpNoteNamespace: 'xmpNote', - }; - - // elements - static const xXmpmeta = 'xmpmeta'; - static const rdfRoot = 'RDF'; - static const rdfDescription = 'Description'; - static const dcSubject = 'subject'; - - // attributes - static const xXmptk = 'xmptk'; - static const rdfAbout = 'about'; - static const xmpMetadataDate = 'MetadataDate'; - static const xmpModifyDate = 'ModifyDate'; - static const xmpNoteHasExtendedXMP = 'HasExtendedXMP'; - - static String prefixOf(String ns) => nsDefaultPrefixes[ns] ?? ''; - - Future> editTags(Set tags) async { - final dataTypes = await setMetadataDateIfMissing(); - - final xmp = await metadataFetchService.getXmp(this); - final extendedXmpString = xmp?.extendedXmpString; - - XmlDocument? xmpDoc; - if (xmp != null) { - final xmpString = xmp.xmpString; - if (xmpString != null) { - xmpDoc = XmlDocument.parse(xmpString); - } - } - if (xmpDoc == null) { - final toolkit = 'Aves v${(await PackageInfo.fromPlatform()).version}'; - final builder = XmlBuilder(); - builder.namespace(xNamespace, prefixOf(xNamespace)); - builder.element(xXmpmeta, namespace: xNamespace, namespaces: { - xNamespace: prefixOf(xNamespace), - }, attributes: { - '${prefixOf(xNamespace)}:$xXmptk': toolkit, - }); - xmpDoc = builder.buildDocument(); - } - - final root = xmpDoc.rootElement; - XmlNode? rdf = root.getElement(rdfRoot, namespace: rdfNamespace); - if (rdf == null) { - final builder = XmlBuilder(); - builder.namespace(rdfNamespace, prefixOf(rdfNamespace)); - builder.element(rdfRoot, namespace: rdfNamespace, namespaces: { - rdfNamespace: prefixOf(rdfNamespace), - }); - // get element because doc fragment cannot be used to edit - root.children.add(builder.buildFragment()); - rdf = root.getElement(rdfRoot, namespace: rdfNamespace)!; - } - - XmlNode? description = rdf.getElement(rdfDescription, namespace: rdfNamespace); - if (description == null) { - final builder = XmlBuilder(); - builder.namespace(rdfNamespace, prefixOf(rdfNamespace)); - builder.element(rdfDescription, namespace: rdfNamespace, attributes: { - '${prefixOf(rdfNamespace)}:$rdfAbout': '', - }); - rdf.children.add(builder.buildFragment()); - // get element because doc fragment cannot be used to edit - description = rdf.getElement(rdfDescription, namespace: rdfNamespace)!; - } - _setNamespaces(description, { - dcNamespace: prefixOf(dcNamespace), - xmpNamespace: prefixOf(xmpNamespace), - }); - - _setStringBag(description, dcSubject, tags, namespace: dcNamespace); - - if (_isMeaningfulXmp(rdf)) { - final modifyDate = DateTime.now(); - description.setAttribute('${prefixOf(xmpNamespace)}:$xmpMetadataDate', _toXmpDate(modifyDate)); - description.setAttribute('${prefixOf(xmpNamespace)}:$xmpModifyDate', _toXmpDate(modifyDate)); - } else { - // clear XMP if there are no attributes or elements worth preserving - xmpDoc = null; - } - - final editedXmp = AvesXmp( - xmpString: xmpDoc?.toXmlString(), - extendedXmpString: extendedXmpString, - ); - - if (canEditIptc) { - final iptc = await metadataFetchService.getIptc(this); - if (iptc != null) { - await _setIptcKeywords(iptc, tags); - } - } - - final newFields = await metadataEditService.setXmp(this, editedXmp); - if (newFields.isNotEmpty) { - dataTypes.add(EntryDataType.catalog); - } - return dataTypes; - } - - Future _setIptcKeywords(List> iptc, Set tags) async { - iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag); - iptc.add({ - 'record': IPTC.applicationRecord, - 'tag': IPTC.keywordsTag, - 'values': tags.map((v) => utf8.encode(v)).toList(), - }); - await metadataEditService.setIptc(this, iptc, postEditScan: false); - } - - int _meaningfulChildrenCount(XmlNode node) => node.children.where((v) => v.nodeType != XmlNodeType.TEXT || v.text.trim().isNotEmpty).length; - - bool _isMeaningfulXmp(XmlNode rdf) { - if (_meaningfulChildrenCount(rdf) > 1) return true; - - final description = rdf.getElement(rdfDescription, namespace: rdfNamespace); - if (description == null) return true; - - if (_meaningfulChildrenCount(description) > 0) return true; - - final hasMeaningfulAttributes = description.attributes.any((v) { - switch (v.name.local) { - case rdfAbout: - return v.value.isNotEmpty; - case xmpMetadataDate: - case xmpModifyDate: - return false; - default: - switch (v.name.prefix) { - case xmlnsPrefix: - return false; - default: - // if the attribute got defined with the prefix as part of the name, - // the prefix is not recognized as such, so we check the full name - return !v.name.qualified.startsWith(xmlnsPrefix); - } - } - }); - return hasMeaningfulAttributes; - } - - // return time zone designator, formatted as `Z` or `+hh:mm` or `-hh:mm` - // as of intl v0.17.0, formatting time zone offset is not implemented - String _xmpTimeZoneDesignator(DateTime date) { - final offsetMinutes = date.timeZoneOffset.inMinutes; - final abs = offsetMinutes.abs(); - final h = abs ~/ Duration.minutesPerHour; - final m = abs % Duration.minutesPerHour; - return '${offsetMinutes.isNegative ? '-' : '+'}${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; - } - - String _toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}'; - - void _setNamespaces(XmlNode node, Map namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); - - void _setStringBag(XmlNode node, String name, Set values, {required String namespace}) { - // remove existing - node.findElements(name, namespace: namespace).toSet().forEach(node.children.remove); - - if (values.isNotEmpty) { - // add new bag - final rootBuilder = XmlBuilder(); - rootBuilder.namespace(namespace, prefixOf(namespace)); - rootBuilder.element(name, namespace: namespace); - node.children.add(rootBuilder.buildFragment()); - - final bagBuilder = XmlBuilder(); - bagBuilder.namespace(rdfNamespace, prefixOf(rdfNamespace)); - bagBuilder.element('Bag', namespace: rdfNamespace, nest: () { - values.forEach((v) { - bagBuilder.element('li', namespace: rdfNamespace, nest: v); - }); - }); - node.children.last.children.add(bagBuilder.buildFragment()); - } - } -} - -@immutable -class AvesXmp extends Equatable { - final String? xmpString; - final String? extendedXmpString; - - @override - List get props => [xmpString, extendedXmpString]; - - const AvesXmp({ - required this.xmpString, - this.extendedXmpString, - }); - - static AvesXmp? fromList(List xmpStrings) { - switch (xmpStrings.length) { - case 0: - return null; - case 1: - return AvesXmp(xmpString: xmpStrings.single); - default: - final byExtending = groupBy(xmpStrings, (v) => v.contains(':HasExtendedXMP=')); - final extending = byExtending[true] ?? []; - final extension = byExtending[false] ?? []; - if (extending.length == 1 && extension.length == 1) { - return AvesXmp( - xmpString: extending.single, - extendedXmpString: extension.single, - ); - } - - // take the first XMP and ignore the rest when the file is weirdly constructed - debugPrint('warning: entry has ${xmpStrings.length} XMP directories, xmpStrings=$xmpStrings'); - return AvesXmp(xmpString: xmpStrings.firstOrNull); - } - } -} diff --git a/lib/model/metadata/date_modifier.dart b/lib/model/metadata/date_modifier.dart index 176d403e7..96ef5ffc0 100644 --- a/lib/model/metadata/date_modifier.dart +++ b/lib/model/metadata/date_modifier.dart @@ -41,7 +41,7 @@ class DateModifier { return DateModifier._private(DateEditAction.shift, fields, shiftMinutes: shiftMinutes); } - factory DateModifier.clear(Set fields) { - return DateModifier._private(DateEditAction.clear, fields); + factory DateModifier.remove(Set fields) { + return DateModifier._private(DateEditAction.remove, fields); } } diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index d5592e9dc..768a87d16 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -10,7 +10,7 @@ enum DateEditAction { copyField, extractFromTitle, shift, - clear, + remove, } enum DateFieldSource { @@ -65,7 +65,7 @@ class MetadataTypes { } extension ExtraMetadataType on MetadataType { - // match `ExifInterface` directory names + // match `metadata-extractor` directory names String getText() { switch (this) { case MetadataType.comment: diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart deleted file mode 100644 index 53c6f1c30..000000000 --- a/lib/ref/xmp.dart +++ /dev/null @@ -1,52 +0,0 @@ -class XMP { - static const propNamespaceSeparator = ':'; - static const structFieldSeparator = '/'; - - // cf https://exiftool.org/TagNames/XMP.html - static const Map namespaces = { - 'acdsee': 'ACDSee', - 'adsml-at': 'AdsML', - 'aux': 'Exif Aux', - 'avm': 'Astronomy Visualization', - 'Camera': 'Camera', - 'cc': 'Creative Commons', - 'crd': 'Camera Raw Defaults', - 'creatorAtom': 'After Effects', - 'crs': 'Camera Raw Settings', - 'dc': 'Dublin Core', - 'drone-dji': 'DJI Drone', - 'dwc': 'Darwin Core', - 'exif': 'Exif', - 'exifEX': 'Exif Ex', - 'GettyImagesGIFT': 'Getty Images', - 'GAudio': 'Google Audio', - 'GDepth': 'Google Depth', - 'GImage': 'Google Image', - 'GIMP': 'GIMP', - 'GCamera': 'Google Camera', - 'GCreations': 'Google Creations', - 'GFocus': 'Google Focus', - 'GPano': 'Google Panorama', - 'illustrator': 'Illustrator', - 'Iptc4xmpCore': 'IPTC Core', - 'Iptc4xmpExt': 'IPTC Extension', - 'lr': 'Lightroom', - 'MicrosoftPhoto': 'Microsoft Photo', - 'mwg-rs': 'Regions', - 'panorama': 'Panorama', - 'PanoStudioXMP': 'PanoramaStudio', - 'pdf': 'PDF', - 'pdfx': 'PDF/X', - 'photomechanic': 'Photo Mechanic', - 'photoshop': 'Photoshop', - 'plus': 'PLUS', - 'pmtm': 'Photomatix', - 'tiff': 'TIFF', - 'xmp': 'Basic', - 'xmpBJ': 'Basic Job Ticket', - 'xmpDM': 'Dynamic Media', - 'xmpMM': 'Media Management', - 'xmpRights': 'Rights Management', - 'xmpTPg': 'Paged-Text', - }; -} diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index 2635c83a0..b9489a691 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/entry_xmp_iptc.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/services/common/services.dart'; @@ -14,9 +13,7 @@ abstract class MetadataEditService { Future> editDate(AvesEntry entry, DateModifier modifier); - Future> setIptc(AvesEntry entry, List>? iptc, {required bool postEditScan}); - - Future> setXmp(AvesEntry entry, AvesXmp? xmp); + Future> editMetadata(AvesEntry entry, Map modifier); Future> removeTypes(AvesEntry entry, Set types); } @@ -91,29 +88,11 @@ class PlatformMetadataEditService implements MetadataEditService { } @override - Future> setIptc(AvesEntry entry, List>? iptc, {required bool postEditScan}) async { + Future> editMetadata(AvesEntry entry, Map metadata) async { try { - final result = await platform.invokeMethod('setIptc', { + final result = await platform.invokeMethod('editMetadata', { 'entry': _toPlatformEntryMap(entry), - 'iptc': iptc, - 'postEditScan': postEditScan, - }); - if (result != null) return (result as Map).cast(); - } on PlatformException catch (e, stack) { - if (!entry.isMissingAtPath) { - await reportService.recordError(e, stack); - } - } - return {}; - } - - @override - Future> setXmp(AvesEntry entry, AvesXmp? xmp) async { - try { - final result = await platform.invokeMethod('setXmp', { - 'entry': _toPlatformEntryMap(entry), - 'xmp': xmp?.xmpString, - 'extendedXmp': xmp?.extendedXmpString, + 'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 3bf7e42e8..5a665af3d 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -1,5 +1,4 @@ import 'package:aves/model/entry.dart'; -import 'package:aves/model/entry_xmp_iptc.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/metadata/overlay.dart'; @@ -7,6 +6,7 @@ import 'package:aves/model/multipage.dart'; import 'package:aves/model/panorama.dart'; import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/metadata/xmp.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; diff --git a/lib/services/metadata/xmp.dart b/lib/services/metadata/xmp.dart new file mode 100644 index 000000000..a6be54191 --- /dev/null +++ b/lib/services/metadata/xmp.dart @@ -0,0 +1,40 @@ +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class AvesXmp extends Equatable { + final String? xmpString; + final String? extendedXmpString; + + @override + List get props => [xmpString, extendedXmpString]; + + const AvesXmp({ + required this.xmpString, + this.extendedXmpString, + }); + + static AvesXmp? fromList(List xmpStrings) { + switch (xmpStrings.length) { + case 0: + return null; + case 1: + return AvesXmp(xmpString: xmpStrings.single); + default: + final byExtending = groupBy(xmpStrings, (v) => v.contains(':HasExtendedXMP=')); + final extending = byExtending[true] ?? []; + final extension = byExtending[false] ?? []; + if (extending.length == 1 && extension.length == 1) { + return AvesXmp( + xmpString: extending.single, + extendedXmpString: extension.single, + ); + } + + // take the first XMP and ignore the rest when the file is weirdly constructed + debugPrint('warning: entry has ${xmpStrings.length} XMP directories, xmpStrings=$xmpStrings'); + return AvesXmp(xmpString: xmpStrings.firstOrNull); + } + } +} diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index c31abc19a..2299830f7 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -22,6 +22,8 @@ class AIcons { static const IconData locationOff = Icons.location_off_outlined; static const IconData mainStorage = Icons.smartphone_outlined; static const IconData privacy = MdiIcons.shieldAccountOutline; + static const IconData rating = Icons.star_border_outlined; + static const IconData ratingFull = Icons.star; static const IconData ratingRejected = MdiIcons.starMinusOutline; static const IconData ratingUnrated = MdiIcons.starOffOutline; static const IconData raw = Icons.raw_on_outlined; @@ -113,7 +115,6 @@ class AIcons { static const IconData geo = Icons.language_outlined; static const IconData motionPhoto = Icons.motion_photos_on_outlined; static const IconData multiPage = Icons.burst_mode_outlined; - static const IconData rating = Icons.star_border_outlined; static const IconData threeSixty = Icons.threesixty_outlined; static const IconData videoThumb = Icons.play_circle_outline; static const IconData selected = Icons.check_circle_outline; diff --git a/lib/utils/xmp_utils.dart b/lib/utils/xmp_utils.dart new file mode 100644 index 000000000..798bb8771 --- /dev/null +++ b/lib/utils/xmp_utils.dart @@ -0,0 +1,273 @@ +import 'package:intl/intl.dart'; +import 'package:xml/xml.dart'; + +class Namespaces { + static const dc = 'http://purl.org/dc/elements/1.1/'; + static const microsoftPhoto = 'http://ns.microsoft.com/photo/1.0/'; + static const rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; + static const x = 'adobe:ns:meta/'; + static const xmp = 'http://ns.adobe.com/xap/1.0/'; + static const xmpNote = 'http://ns.adobe.com/xmp/note/'; + + static final defaultPrefixes = { + dc: 'dc', + microsoftPhoto: 'MicrosoftPhoto', + rdf: 'rdf', + x: 'x', + xmp: 'xmp', + xmpNote: 'xmpNote', + }; +} + +class XMP { + static const xmlnsPrefix = 'xmlns'; + static const propNamespaceSeparator = ':'; + static const structFieldSeparator = '/'; + + static String prefixOf(String ns) => Namespaces.defaultPrefixes[ns] ?? ''; + + // elements + static const xXmpmeta = 'xmpmeta'; + static const rdfRoot = 'RDF'; + static const rdfDescription = 'Description'; + static const dcSubject = 'subject'; + static const msPhotoRating = 'Rating'; + static const xmpRating = 'Rating'; + + // attributes + static const xXmptk = 'xmptk'; + static const rdfAbout = 'about'; + static const xmpMetadataDate = 'MetadataDate'; + static const xmpModifyDate = 'ModifyDate'; + static const xmpNoteHasExtendedXMP = 'HasExtendedXMP'; + + // for `rdf:Description` node only + static bool _hasMeaningfulChildren(XmlNode node) => node.children.any((v) => v.nodeType != XmlNodeType.TEXT || v.text.trim().isNotEmpty); + + // for `rdf:Description` node only + static bool _hasMeaningfulAttributes(XmlNode description) { + final hasMeaningfulAttributes = description.attributes.any((v) { + switch (v.name.local) { + case rdfAbout: + case xmpMetadataDate: + case xmpModifyDate: + return false; + default: + switch (v.name.prefix) { + case xmlnsPrefix: + return false; + default: + // if the attribute got defined with the prefix as part of the name, + // the prefix is not recognized as such, so we check the full name + return !v.name.qualified.startsWith(xmlnsPrefix); + } + } + }); + return hasMeaningfulAttributes; + } + + // return time zone designator, formatted as `Z` or `+hh:mm` or `-hh:mm` + // as of intl v0.17.0, formatting time zone offset is not implemented + static String _xmpTimeZoneDesignator(DateTime date) { + final offsetMinutes = date.timeZoneOffset.inMinutes; + final abs = offsetMinutes.abs(); + final h = abs ~/ Duration.minutesPerHour; + final m = abs % Duration.minutesPerHour; + return '${offsetMinutes.isNegative ? '-' : '+'}${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; + } + + static String toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}'; + + static void _addNamespaces(XmlNode node, Map namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); + + // remove elements and attributes + static bool _removeElements(List nodes, String name, String namespace) { + var removed = false; + nodes.forEach((node) { + final elements = node.findElements(name, namespace: namespace).toSet(); + if (elements.isNotEmpty) { + elements.forEach(node.children.remove); + removed = true; + } + + if (node.getAttributeNode(name, namespace: namespace) != null) { + node.removeAttribute(name, namespace: namespace); + removed = true; + } + }); + return removed; + } + + // remove attribute/element from all nodes, and set attribute with new value, if any, in the first node + static void setAttribute( + List nodes, + String name, + String? value, { + required String namespace, + required XmpEditStrategy strat, + }) { + final removed = _removeElements(nodes, name, namespace); + + if (value == null) return; + + if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { + final node = nodes.first; + _addNamespaces(node, {namespace: prefixOf(namespace)}); + + // use qualified name, otherwise the namespace prefix is not added + final qualifiedName = '${prefixOf(namespace)}$propNamespaceSeparator$name'; + node.setAttribute(qualifiedName, value); + } + } + + // remove attribute/element from all nodes, and create element with new value, if any, in the first node + static void setElement( + List nodes, + String name, + String? value, { + required String namespace, + required XmpEditStrategy strat, + }) { + final removed = _removeElements(nodes, name, namespace); + + if (value == null) return; + + if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { + final node = nodes.first; + _addNamespaces(node, {namespace: prefixOf(namespace)}); + + final builder = XmlBuilder(); + builder.namespace(namespace, prefixOf(namespace)); + builder.element(name, namespace: namespace, nest: () { + builder.text(value); + }); + node.children.add(builder.buildFragment()); + } + } + + // remove bag from all nodes, and create bag with new values, if any, in the first node + static void setStringBag( + List nodes, + String name, + Set values, { + required String namespace, + required XmpEditStrategy strat, + }) { + // remove existing + final removed = _removeElements(nodes, name, namespace); + + if (values.isEmpty) return; + + if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { + final node = nodes.first; + _addNamespaces(node, {namespace: prefixOf(namespace)}); + + // add new bag + final rootBuilder = XmlBuilder(); + rootBuilder.namespace(namespace, prefixOf(namespace)); + rootBuilder.element(name, namespace: namespace); + node.children.add(rootBuilder.buildFragment()); + + final bagBuilder = XmlBuilder(); + bagBuilder.namespace(Namespaces.rdf, prefixOf(Namespaces.rdf)); + bagBuilder.element('Bag', namespace: Namespaces.rdf, nest: () { + values.forEach((v) { + bagBuilder.element('li', namespace: Namespaces.rdf, nest: v); + }); + }); + node.children.last.children.add(bagBuilder.buildFragment()); + } + } + + static Future edit( + String? xmpString, + Future Function() toolkit, + void Function(List descriptions) apply, { + DateTime? modifyDate, + }) async { + XmlDocument? xmpDoc; + if (xmpString != null) { + xmpDoc = XmlDocument.parse(xmpString); + } + if (xmpDoc == null) { + final builder = XmlBuilder(); + builder.namespace(Namespaces.x, prefixOf(Namespaces.x)); + builder.element(xXmpmeta, namespace: Namespaces.x, namespaces: { + Namespaces.x: prefixOf(Namespaces.x), + }, attributes: { + '${prefixOf(Namespaces.x)}$propNamespaceSeparator$xXmptk': await toolkit(), + }); + xmpDoc = builder.buildDocument(); + } + + final root = xmpDoc.rootElement; + XmlNode? rdf = root.getElement(rdfRoot, namespace: Namespaces.rdf); + if (rdf == null) { + final builder = XmlBuilder(); + builder.namespace(Namespaces.rdf, prefixOf(Namespaces.rdf)); + builder.element(rdfRoot, namespace: Namespaces.rdf, namespaces: { + Namespaces.rdf: prefixOf(Namespaces.rdf), + }); + // get element because doc fragment cannot be used to edit + root.children.add(builder.buildFragment()); + rdf = root.getElement(rdfRoot, namespace: Namespaces.rdf)!; + } + + // content can be split in multiple `rdf:Description` elements + List descriptions = rdf.children.where((node) { + return node is XmlElement && node.name.local == rdfDescription && node.name.namespaceUri == Namespaces.rdf; + }).toList(); + + if (descriptions.isEmpty) { + final builder = XmlBuilder(); + builder.namespace(Namespaces.rdf, prefixOf(Namespaces.rdf)); + builder.element(rdfDescription, namespace: Namespaces.rdf, attributes: { + '${prefixOf(Namespaces.rdf)}$propNamespaceSeparator$rdfAbout': '', + }); + rdf.children.add(builder.buildFragment()); + // get element because doc fragment cannot be used to edit + descriptions.add(rdf.getElement(rdfDescription, namespace: Namespaces.rdf)!); + } + apply(descriptions); + + // clean description nodes with no children + descriptions.where((v) => !_hasMeaningfulChildren(v)).forEach((v) => v.children.clear()); + + // remove superfluous description nodes + rdf.children.removeWhere((v) => !_hasMeaningfulChildren(v) && !_hasMeaningfulAttributes(v)); + + if (rdf.children.isNotEmpty) { + _addNamespaces(descriptions.first, {Namespaces.xmp: prefixOf(Namespaces.xmp)}); + final xmpDate = toXmpDate(modifyDate ?? DateTime.now()); + setAttribute(descriptions, xmpMetadataDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always); + setAttribute(descriptions, xmpModifyDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always); + } else { + // clear XMP if there are no attributes or elements worth preserving + xmpDoc = null; + } + + return xmpDoc?.toXmlString(); + } + + static String? toMsPhotoRating(int? rating) { + if (rating == null) return null; + switch (rating) { + case 5: + return '99'; + case 4: + return '75'; + case 3: + return '50'; + case 2: + return '25'; + case 1: + return '1'; + case 0: + return null; + case -1: + return '-1'; + } + } +} + +enum XmpEditStrategy { always, updateIfPresent } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 1d2e33d68..1bdaedae4 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -259,6 +259,7 @@ class _CollectionAppBarState extends State with SingleTickerPr _buildRotateAndFlipMenuItems(context, canApply: canApply), ...[ EntrySetAction.editDate, + EntrySetAction.editRating, EntrySetAction.editTags, EntrySetAction.removeMetadata, ].map((action) => _toMenuItem(action, enabled: canApply(action))), @@ -427,6 +428,7 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: _actionDelegate.onActionSelected(context, action); diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 36f36a0bb..ab8211a1b 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -6,7 +6,7 @@ import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/entry_xmp_iptc.dart'; +import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; @@ -77,6 +77,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: return appMode == AppMode.main && isSelecting; @@ -118,6 +119,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: return hasSelection; @@ -177,6 +179,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.editDate: _editDate(context); break; + case EntrySetAction.editRating: + _editRating(context); + break; case EntrySetAction.editTags: _editTags(context); break; @@ -513,6 +518,19 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier)); } + Future _editRating(BuildContext context) async { + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + + final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditRating); + if (todoItems == null || todoItems.isEmpty) return; + + final rating = await selectRating(context, todoItems); + if (rating == null) return; + + await _edit(context, selection, todoItems, (entry) => entry.editRating(rating)); + } + Future _editTags(BuildContext context) async { final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 5b383cbc7..9b84e7d32 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -5,6 +5,7 @@ import 'package:aves/ref/mime_types.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart'; import 'package:flutter/material.dart'; @@ -22,6 +23,18 @@ mixin EntryEditorMixin { return modifier; } + Future selectRating(BuildContext context, Set entries) async { + if (entries.isEmpty) return null; + + final rating = await showDialog( + context: context, + builder: (context) => EditEntryRatingDialog( + entry: entries.first, + ), + ); + return rating; + } + Future>?> selectTags(BuildContext context, Set entries) async { if (entries.isEmpty) return null; diff --git a/lib/widgets/common/thumbnail/overlay.dart b/lib/widgets/common/thumbnail/overlay.dart index 5b35a8ccf..795a7e814 100644 --- a/lib/widgets/common/thumbnail/overlay.dart +++ b/lib/widgets/common/thumbnail/overlay.dart @@ -20,12 +20,12 @@ class ThumbnailEntryOverlay extends StatelessWidget { Widget build(BuildContext context) { final children = [ if (entry.hasGps && context.select((t) => t.showLocation)) const GpsIcon(), + if (entry.rating != 0 && context.select((t) => t.showRating)) RatingIcon(entry: entry), if (entry.isVideo) VideoIcon(entry: entry) else if (entry.isAnimated) const AnimatedImageIcon() else ...[ - if (entry.rating != 0 && context.select((t) => t.showRating)) RatingIcon(entry: entry), if (entry.isRaw && context.select((t) => t.showRaw)) const RawIcon(), if (entry.isGeotiff) const GeotiffIcon(), if (entry.is360) const SphericalImageIcon(), diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart index 9fb584ac3..5b3b280ab 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart @@ -277,8 +277,8 @@ class _EditEntryDateDialogState extends State { return l10n.editEntryDateDialogExtractFromTitle; case DateEditAction.shift: return l10n.editEntryDateDialogShift; - case DateEditAction.clear: - return l10n.editEntryDateDialogClear; + case DateEditAction.remove: + return l10n.actionRemove; } } @@ -347,8 +347,8 @@ class _EditEntryDateDialogState extends State { case DateEditAction.shift: final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1); return DateModifier.shift(_fields, shiftTotalMinutes); - case DateEditAction.clear: - return DateModifier.clear(_fields); + case DateEditAction.remove: + return DateModifier.remove(_fields); } } diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart new file mode 100644 index 000000000..5ab85c56f --- /dev/null +++ b/lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart @@ -0,0 +1,136 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:flutter/material.dart'; + +class EditEntryRatingDialog extends StatefulWidget { + final AvesEntry entry; + + const EditEntryRatingDialog({ + Key? key, + required this.entry, + }) : super(key: key); + + @override + _EditEntryRatingDialogState createState() => _EditEntryRatingDialogState(); +} + +class _EditEntryRatingDialogState extends State { + late _RatingAction _action; + late int _rating; + + @override + void initState() { + super.initState(); + final entryRating = widget.entry.rating; + switch (entryRating) { + case -1: + _action = _RatingAction.rejected; + _rating = 0; + break; + case 0: + _action = _RatingAction.unrated; + _rating = 0; + break; + default: + _action = _RatingAction.set; + _rating = entryRating; + } + } + + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: Builder(builder: (context) { + final l10n = context.l10n; + + return AvesDialog( + title: l10n.editEntryRatingDialogTitle, + scrollableContent: [ + RadioListTile<_RatingAction>( + value: _RatingAction.set, + groupValue: _action, + onChanged: (v) => setState(() => _action = v!), + title: Wrap( + children: [ + ...List.generate(5, (i) { + final thisRating = i + 1; + return GestureDetector( + onTap: () => setState(() { + _action = _RatingAction.set; + _rating = thisRating; + }), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + _rating < thisRating ? AIcons.rating : AIcons.ratingFull, + color: _rating < thisRating ? Colors.grey : Colors.amber, + ), + ), + ); + }) + ], + ), + ), + RadioListTile<_RatingAction>( + value: _RatingAction.rejected, + groupValue: _action, + onChanged: (v) => setState(() { + _action = v!; + _rating = 0; + }), + title: Text(l10n.filterRatingRejectedLabel), + ), + RadioListTile<_RatingAction>( + value: _RatingAction.unrated, + groupValue: _action, + onChanged: (v) => setState(() { + _action = v!; + _rating = 0; + }), + title: Text(l10n.filterRatingUnratedLabel), + ), + ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: isValid ? () => _submit(context) : null, + child: Text(l10n.applyButtonLabel), + ), + ], + ); + }), + ), + ); + } + + bool get isValid => !(_action == _RatingAction.set && _rating <= 0); + + void _submit(BuildContext context) { + late int entryRating; + switch (_action) { + case _RatingAction.set: + entryRating = _rating; + break; + case _RatingAction.rejected: + entryRating = -1; + break; + case _RatingAction.unrated: + entryRating = 0; + break; + } + Navigator.pop(context, entryRating); + } +} + +enum _RatingAction { set, rejected, unrated } diff --git a/lib/widgets/settings/navigation/drawer_tab_albums.dart b/lib/widgets/settings/navigation/drawer_tab_albums.dart index b7d42d4f8..a0dbad668 100644 --- a/lib/widgets/settings/navigation/drawer_tab_albums.dart +++ b/lib/widgets/settings/navigation/drawer_tab_albums.dart @@ -43,7 +43,7 @@ class _DrawerAlbumTabState extends State { onPressed: () { setState(() => widget.items.remove(album)); }, - tooltip: context.l10n.removeTooltip, + tooltip: context.l10n.actionRemove, ), ); }, diff --git a/lib/widgets/settings/privacy/hidden_items.dart b/lib/widgets/settings/privacy/hidden_items.dart index 48e5a6d19..0c1b0f17b 100644 --- a/lib/widgets/settings/privacy/hidden_items.dart +++ b/lib/widgets/settings/privacy/hidden_items.dart @@ -154,7 +154,7 @@ class _HiddenPaths extends StatelessWidget { onPressed: () { context.read().changeFilterVisibility({pathFilter}, true); }, - tooltip: context.l10n.removeTooltip, + tooltip: context.l10n.actionRemove, ), )), ], diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index c5ea64f74..1c20cba58 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/events.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/entry_xmp_iptc.dart'; +import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -25,6 +25,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi switch (action) { // general case EntryInfoAction.editDate: + case EntryInfoAction.editRating: case EntryInfoAction.editTags: case EntryInfoAction.removeMetadata: return true; @@ -39,6 +40,8 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi // general case EntryInfoAction.editDate: return entry.canEditDate; + case EntryInfoAction.editRating: + return entry.canEditRating; case EntryInfoAction.editTags: return entry.canEditTags; case EntryInfoAction.removeMetadata: @@ -56,6 +59,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi case EntryInfoAction.editDate: await _editDate(context); break; + case EntryInfoAction.editRating: + await _editRating(context); + break; case EntryInfoAction.editTags: await _editTags(context); break; @@ -77,6 +83,13 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi await edit(context, () => entry.editDate(modifier)); } + Future _editRating(BuildContext context) async { + final rating = await selectRating(context, {entry}); + if (rating == null) return; + + await edit(context, () => entry.editRating(rating)); + } + Future _editTags(BuildContext context) async { final newTagsByEntry = await selectTags(context, {entry}); if (newTagsByEntry == null) return; diff --git a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart index 317ec7e5d..d4154c64e 100644 --- a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart @@ -1,7 +1,7 @@ import 'package:aves/ref/brand_colors.dart'; -import 'package:aves/ref/xmp.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/string_utils.dart'; +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/crs.dart'; @@ -66,7 +66,55 @@ class XmpNamespace extends Equatable { } } - String get displayTitle => XMP.namespaces[namespace] ?? namespace; + // cf https://exiftool.org/TagNames/XMP.html + static const Map nsTitles = { + 'acdsee': 'ACDSee', + 'adsml-at': 'AdsML', + 'aux': 'Exif Aux', + 'avm': 'Astronomy Visualization', + 'Camera': 'Camera', + 'cc': 'Creative Commons', + 'crd': 'Camera Raw Defaults', + 'creatorAtom': 'After Effects', + 'crs': 'Camera Raw Settings', + 'dc': 'Dublin Core', + 'drone-dji': 'DJI Drone', + 'dwc': 'Darwin Core', + 'exif': 'Exif', + 'exifEX': 'Exif Ex', + 'GettyImagesGIFT': 'Getty Images', + 'GAudio': 'Google Audio', + 'GDepth': 'Google Depth', + 'GImage': 'Google Image', + 'GIMP': 'GIMP', + 'GCamera': 'Google Camera', + 'GCreations': 'Google Creations', + 'GFocus': 'Google Focus', + 'GPano': 'Google Panorama', + 'illustrator': 'Illustrator', + 'Iptc4xmpCore': 'IPTC Core', + 'Iptc4xmpExt': 'IPTC Extension', + 'lr': 'Lightroom', + 'MicrosoftPhoto': 'Microsoft Photo', + 'mwg-rs': 'Regions', + 'panorama': 'Panorama', + 'PanoStudioXMP': 'PanoramaStudio', + 'pdf': 'PDF', + 'pdfx': 'PDF/X', + 'photomechanic': 'Photo Mechanic', + 'photoshop': 'Photoshop', + 'plus': 'PLUS', + 'pmtm': 'Photomatix', + 'tiff': 'TIFF', + 'xmp': 'Basic', + 'xmpBJ': 'Basic Job Ticket', + 'xmpDM': 'Dynamic Media', + 'xmpMM': 'Media Management', + 'xmpRights': 'Rights Management', + 'xmpTPg': 'Paged-Text', + }; + + String get displayTitle => nsTitles[namespace] ?? namespace; Map get buildProps => rawProps; diff --git a/lib/widgets/viewer/info/metadata/xmp_tile.dart b/lib/widgets/viewer/info/metadata/xmp_tile.dart index 7c738e002..01dd5a393 100644 --- a/lib/widgets/viewer/info/metadata/xmp_tile.dart +++ b/lib/widgets/viewer/info/metadata/xmp_tile.dart @@ -1,8 +1,8 @@ import 'dart:collection'; import 'package:aves/model/entry.dart'; -import 'package:aves/ref/xmp.dart'; import 'package:aves/utils/color_utils.dart'; +import 'package:aves/utils/xmp_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:collection/collection.dart'; diff --git a/test/utils/xmp_utils_test.dart b/test/utils/xmp_utils_test.dart new file mode 100644 index 000000000..24dcc403f --- /dev/null +++ b/test/utils/xmp_utils_test.dart @@ -0,0 +1,390 @@ +import 'package:aves/model/entry_metadata_edition.dart'; +import 'package:aves/utils/xmp_utils.dart'; +import 'package:test/test.dart'; +import 'package:xml/xml.dart'; + +void main() { + const toolkit = 'test-toolkit'; + + String? _toExpect(String? xmpString) => xmpString != null + ? XmlDocument.parse(xmpString).toXmlString( + pretty: true, + sortAttributes: (a, b) => a.name.qualified.compareTo(b.name.qualified), + ) + : null; + + const inMultiDescriptionRatings = ''' + + + + 5 + + + 99 + + + +'''; + const inRatingAttribute = ''' + + + + + +'''; + const inRatingElement = ''' + + + + 5 + + + +'''; + const inSubjects = ''' + + + + + + the king + + + + + +'''; + const inSubjectsCreator = ''' + + + + + + c + + + + + a + b + + + + + +'''; + + test('Set tags without existing XMP', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + null, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {'one', 'two'}), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + one + two + + + + + +''')); + }); + + test('Set tags to XMP with ratings (multiple descriptions)', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inMultiDescriptionRatings, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {'one', 'two'}), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + 5 + + + one + two + + + + + 99 + + + +''')); + }); + + test('Set tags to XMP with subjects only', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {'one', 'two'}), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + one + two + + + + + +''')); + }); + + test('Remove tags from XMP with subjects only', () async { + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {}), + )), + _toExpect(null)); + }); + + test('Remove tags from XMP with subjects and creator', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inSubjectsCreator, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editTagsXmp(descriptions, {}), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + c + + + + + +''')); + }); + + test('Set rating without existing XMP', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + null, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + +''')); + }); + + test('Set rating to XMP with ratings (multiple descriptions)', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inMultiDescriptionRatings, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + +''')); + }); + + test('Set rating to XMP with rating attribute', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inRatingAttribute, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + +''')); + }); + + test('Set rating to XMP with rating element', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inRatingElement, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + +''')); + }); + + test('Set rating to XMP with subjects only', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, 3), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + the king + + + + + +''')); + }); + + test('Remove rating from XMP with subjects only', () async { + final modifyDate = DateTime.now(); + final xmpDate = XMP.toXmpDate(modifyDate); + + expect( + _toExpect(await XMP.edit( + inSubjects, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, null), + modifyDate: modifyDate, + )), + _toExpect(''' + + + + + + the king + + + + + +''')); + }); + + test('Remove rating from XMP with ratings (multiple descriptions)', () async { + final modifyDate = DateTime.now(); + + expect( + _toExpect(await XMP.edit( + inMultiDescriptionRatings, + () async => toolkit, + (descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, null), + modifyDate: modifyDate, + )), + _toExpect(null)); + }); +} diff --git a/untranslated.json b/untranslated.json index bc3e1d959..e9cb2d571 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,5 +1,6 @@ { "de": [ + "entryInfoActionEditRating", "filterRatingUnratedLabel", "filterRatingRejectedLabel", "missingSystemFilePickerDialogTitle", @@ -8,22 +9,28 @@ "editEntryDateDialogCopyField", "editEntryDateDialogSourceFileModifiedDate", "editEntryDateDialogTargetFieldsHeader", + "editEntryRatingDialogTitle", "collectionSortRating", "searchSectionRating", "settingsThumbnailShowRating" ], "fr": [ + "entryInfoActionEditRating", "missingSystemFilePickerDialogTitle", - "missingSystemFilePickerDialogMessage" + "missingSystemFilePickerDialogMessage", + "editEntryRatingDialogTitle" ], "ko": [ + "entryInfoActionEditRating", "missingSystemFilePickerDialogTitle", - "missingSystemFilePickerDialogMessage" + "missingSystemFilePickerDialogMessage", + "editEntryRatingDialogTitle" ], "ru": [ + "entryInfoActionEditRating", "filterRatingUnratedLabel", "filterRatingRejectedLabel", "missingSystemFilePickerDialogTitle", @@ -32,6 +39,7 @@ "editEntryDateDialogCopyField", "editEntryDateDialogSourceFileModifiedDate", "editEntryDateDialogTargetFieldsHeader", + "editEntryRatingDialogTitle", "collectionSortRating", "searchSectionRating", "settingsThumbnailShowRating"