#143 rating: edition

This commit is contained in:
Thibault Deckers 2022-01-03 17:34:04 +09:00
parent e5fe3e980f
commit f3581562d4
35 changed files with 1179 additions and 471 deletions

View file

@ -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<List<FieldMap>>("iptc")
private fun editMetadata(call: MethodCall, result: MethodChannel.Result) {
val metadata = call.argument<FieldMap>("metadata")
val entryMap = call.argument<FieldMap>("entry")
val postEditScan = call.argument<Boolean>("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<String>("xmp")
val extendedXmp = call.argument<String>("extendedXmp")
val entryMap = call.argument<FieldMap>("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)
})
}

View file

@ -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<String, Int>()
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<String>("mimeType")
val uri = call.argument<String>("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) {

View file

@ -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"

View file

@ -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<FieldMap>? = null,
) {
val newFields = HashMap<String, Any?>()
if (modifier.containsKey("iptc")) {
val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance<FieldMap>()
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<String, Any?>()
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(

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -22,7 +22,7 @@
"nextTooltip": "다음",
"showTooltip": "보기",
"hideTooltip": "숨기기",
"removeTooltip": "제거",
"actionRemove": "제거",
"resetButtonTooltip": "복원",
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
@ -184,7 +184,6 @@
"editEntryDateDialogCopyField": "다른 날짜에서 지정",
"editEntryDateDialogExtractFromTitle": "제목에서 추출",
"editEntryDateDialogShift": "시간 이동",
"editEntryDateDialogClear": "삭제",
"editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜",
"editEntryDateDialogTargetFieldsHeader": "수정할 필드",
"editEntryDateDialogHours": "시간",

View file

@ -22,7 +22,7 @@
"nextTooltip": "Следующий",
"showTooltip": "Показать",
"hideTooltip": "Скрыть",
"removeTooltip": "Удалить",
"actionRemove": "Удалить",
"resetButtonTooltip": "Сбросить",
"doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.",
@ -180,7 +180,6 @@
"editEntryDateDialogTitle": "Дата и время",
"editEntryDateDialogExtractFromTitle": "Извлечь из названия",
"editEntryDateDialogShift": "Сдвиг",
"editEntryDateDialogClear": "Очистить",
"editEntryDateDialogHours": "Часов",
"editEntryDateDialogMinutes": "Минут",

View file

@ -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:

View file

@ -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:

View file

@ -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<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {

View file

@ -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<Set<EntryDataType>> editTags(Set<String> tags) async {
final Map<MetadataType, dynamic> 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<Set<EntryDataType>> editRating(int? rating) async {
final Map<MetadataType, dynamic> 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<Map<String, dynamic>> iptc, Set<String> 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<XmlNode> descriptions, Set<String> tags) {
XMP.setStringBag(
descriptions,
XMP.dcSubject,
tags,
namespace: Namespaces.dc,
strat: XmpEditStrategy.always,
);
}
@visibleForTesting
static void editRatingXmp(List<XmlNode> 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<Map<String, String?>> _editXmp(void Function(List<XmlNode> 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,
};
}
}

View file

@ -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<Set<EntryDataType>> editTags(Set<String> 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<void> _setIptcKeywords(List<Map<String, dynamic>> iptc, Set<String> 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<String, String> namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri));
void _setStringBag(XmlNode node, String name, Set<String> 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<Object?> get props => [xmpString, extendedXmpString];
const AvesXmp({
required this.xmpString,
this.extendedXmpString,
});
static AvesXmp? fromList(List<String> xmpStrings) {
switch (xmpStrings.length) {
case 0:
return null;
case 1:
return AvesXmp(xmpString: xmpStrings.single);
default:
final byExtending = groupBy<String, bool>(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);
}
}
}

View file

@ -41,7 +41,7 @@ class DateModifier {
return DateModifier._private(DateEditAction.shift, fields, shiftMinutes: shiftMinutes);
}
factory DateModifier.clear(Set<MetadataField> fields) {
return DateModifier._private(DateEditAction.clear, fields);
factory DateModifier.remove(Set<MetadataField> fields) {
return DateModifier._private(DateEditAction.remove, fields);
}
}

View file

@ -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:

View file

@ -1,52 +0,0 @@
class XMP {
static const propNamespaceSeparator = ':';
static const structFieldSeparator = '/';
// cf https://exiftool.org/TagNames/XMP.html
static const Map<String, String> 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',
};
}

View file

@ -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<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan});
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp);
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> modifier);
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
}
@ -91,29 +88,11 @@ class PlatformMetadataEditService implements MetadataEditService {
}
@override
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan}) async {
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> metadata) async {
try {
final result = await platform.invokeMethod('setIptc', <String, dynamic>{
final result = await platform.invokeMethod('editMetadata', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'iptc': iptc,
'postEditScan': postEditScan,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return {};
}
@override
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp) async {
try {
final result = await platform.invokeMethod('setXmp', <String, dynamic>{
'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<String, dynamic>();
} on PlatformException catch (e, stack) {

View file

@ -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';

View file

@ -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<Object?> get props => [xmpString, extendedXmpString];
const AvesXmp({
required this.xmpString,
this.extendedXmpString,
});
static AvesXmp? fromList(List<String> xmpStrings) {
switch (xmpStrings.length) {
case 0:
return null;
case 1:
return AvesXmp(xmpString: xmpStrings.single);
default:
final byExtending = groupBy<String, bool>(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);
}
}
}

View file

@ -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;

273
lib/utils/xmp_utils.dart Normal file
View file

@ -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<String, String> namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri));
// remove elements and attributes
static bool _removeElements(List<XmlNode> 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<XmlNode> 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<XmlNode> 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<XmlNode> nodes,
String name,
Set<String> 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<String?> edit(
String? xmpString,
Future<String> Function() toolkit,
void Function(List<XmlNode> 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<XmlNode> 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 }

View file

@ -259,6 +259,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
case EntrySetAction.rotateCW:
case EntrySetAction.flip:
case EntrySetAction.editDate:
case EntrySetAction.editRating:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata:
_actionDelegate.onActionSelected(context, action);

View file

@ -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<void> _editRating(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
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<void> _editTags(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);

View file

@ -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<int?> selectRating(BuildContext context, Set<AvesEntry> entries) async {
if (entries.isEmpty) return null;
final rating = await showDialog<int?>(
context: context,
builder: (context) => EditEntryRatingDialog(
entry: entries.first,
),
);
return rating;
}
Future<Map<AvesEntry, Set<String>>?> selectTags(BuildContext context, Set<AvesEntry> entries) async {
if (entries.isEmpty) return null;

View file

@ -20,12 +20,12 @@ class ThumbnailEntryOverlay extends StatelessWidget {
Widget build(BuildContext context) {
final children = [
if (entry.hasGps && context.select<GridThemeData, bool>((t) => t.showLocation)) const GpsIcon(),
if (entry.rating != 0 && context.select<GridThemeData, bool>((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<GridThemeData, bool>((t) => t.showRating)) RatingIcon(entry: entry),
if (entry.isRaw && context.select<GridThemeData, bool>((t) => t.showRaw)) const RawIcon(),
if (entry.isGeotiff) const GeotiffIcon(),
if (entry.is360) const SphericalImageIcon(),

View file

@ -277,8 +277,8 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
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<EditEntryDateDialog> {
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);
}
}

View file

@ -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<EditEntryRatingDialog> {
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 }

View file

@ -43,7 +43,7 @@ class _DrawerAlbumTabState extends State<DrawerAlbumTab> {
onPressed: () {
setState(() => widget.items.remove(album));
},
tooltip: context.l10n.removeTooltip,
tooltip: context.l10n.actionRemove,
),
);
},

View file

@ -154,7 +154,7 @@ class _HiddenPaths extends StatelessWidget {
onPressed: () {
context.read<CollectionSource>().changeFilterVisibility({pathFilter}, true);
},
tooltip: context.l10n.removeTooltip,
tooltip: context.l10n.actionRemove,
),
)),
],

View file

@ -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<void> _editRating(BuildContext context) async {
final rating = await selectRating(context, {entry});
if (rating == null) return;
await edit(context, () => entry.editRating(rating));
}
Future<void> _editTags(BuildContext context) async {
final newTagsByEntry = await selectTags(context, {entry});
if (newTagsByEntry == null) return;

View file

@ -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<String, String> 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<String, String> get buildProps => rawProps;

View file

@ -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';

View file

@ -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 = '''
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" rdf:about="uuid:faf5bdd5-ba3d-11da-ad31-d33d75182f1b">
<xmp:Rating>5</xmp:Rating>
</rdf:Description>
<rdf:Description xmlns:MicrosoftPhoto="http://ns.microsoft.com/photo/1.0/" rdf:about="uuid:faf5bdd5-ba3d-11da-ad31-d33d75182f1b">
<MicrosoftPhoto:Rating>99</MicrosoftPhoto:Rating>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
''';
const inRatingAttribute = '''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" rdf:about="" xmp:Rating="5" />
</rdf:RDF>
</x:xmpmeta>
''';
const inRatingElement = '''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" rdf:about="">
<xmp:Rating>5</xmp:Rating>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
''';
const inSubjects = '''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:subject>
<rdf:Bag>
<rdf:li>the king</rdf:li>
</rdf:Bag>
</dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
''';
const inSubjectsCreator = '''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmp:ModifyDate="2021-12-24T21:41:46+09:00"
xmp:MetadataDate="2021-12-24T21:41:46+09:00">
<dc:creator>
<rdf:Seq>
<rdf:li>c</rdf:li>
</rdf:Seq>
</dc:creator>
<dc:subject>
<rdf:Bag>
<rdf:li>a</rdf:li>
<rdf:li>b</rdf:li>
</rdf:Bag>
</dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
''';
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('''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="$toolkit">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmp:MetadataDate="$xmpDate"
xmp:ModifyDate="$xmpDate">
<dc:subject>
<rdf:Bag>
<rdf:li>one</rdf:li>
<rdf:li>two</rdf:li>
</rdf:Bag>
</dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
'''));
});
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('''
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="uuid:faf5bdd5-ba3d-11da-ad31-d33d75182f1b"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmp:MetadataDate="$xmpDate"
xmp:ModifyDate="$xmpDate">
<xmp:Rating>5</xmp:Rating>
<dc:subject>
<rdf:Bag>
<rdf:li>one</rdf:li>
<rdf:li>two</rdf:li>
</rdf:Bag>
</dc:subject>
</rdf:Description>
<rdf:Description xmlns:MicrosoftPhoto="http://ns.microsoft.com/photo/1.0/" rdf:about="uuid:faf5bdd5-ba3d-11da-ad31-d33d75182f1b">
<MicrosoftPhoto:Rating>99</MicrosoftPhoto:Rating>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
'''));
});
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('''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmp:MetadataDate="$xmpDate"
xmp:ModifyDate="$xmpDate">
<dc:subject>
<rdf:Bag>
<rdf:li>one</rdf:li>
<rdf:li>two</rdf:li>
</rdf:Bag>
</dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
'''));
});
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('''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmp:MetadataDate="$xmpDate"
xmp:ModifyDate="$xmpDate">
<dc:creator>
<rdf:Seq>
<rdf:li>c</rdf:li>
</rdf:Seq>
</dc:creator>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
'''));
});
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('''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="$toolkit">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmp:Rating="3"
xmp:MetadataDate="$xmpDate"
xmp:ModifyDate="$xmpDate" />
</rdf:RDF>
</x:xmpmeta>
'''));
});
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('''
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="uuid:faf5bdd5-ba3d-11da-ad31-d33d75182f1b"
xmlns:MicrosoftPhoto="http://ns.microsoft.com/photo/1.0/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
MicrosoftPhoto:Rating="50"
xmp:Rating="3"
xmp:MetadataDate="$xmpDate"
xmp:ModifyDate="$xmpDate" />
</rdf:RDF>
</x:xmpmeta>
'''));
});
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('''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmp:Rating="3"
xmp:MetadataDate="$xmpDate"
xmp:ModifyDate="$xmpDate" />
</rdf:RDF>
</x:xmpmeta>
'''));
});
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('''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmp:Rating="3"
xmp:MetadataDate="$xmpDate"
xmp:ModifyDate="$xmpDate" />
</rdf:RDF>
</x:xmpmeta>
'''));
});
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('''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmp:Rating="3"
xmp:MetadataDate="$xmpDate"
xmp:ModifyDate="$xmpDate">
<dc:subject>
<rdf:Bag>
<rdf:li>the king</rdf:li>
</rdf:Bag>
</dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
'''));
});
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('''
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmp:MetadataDate="$xmpDate"
xmp:ModifyDate="$xmpDate">
<dc:subject>
<rdf:Bag>
<rdf:li>the king</rdf:li>
</rdf:Bag>
</dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
'''));
});
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));
});
}

View file

@ -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"