#143 rating: edition
This commit is contained in:
parent
e5fe3e980f
commit
f3581562d4
35 changed files with 1179 additions and 471 deletions
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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",
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
"nextTooltip": "다음",
|
||||
"showTooltip": "보기",
|
||||
"hideTooltip": "숨기기",
|
||||
"removeTooltip": "제거",
|
||||
"actionRemove": "제거",
|
||||
"resetButtonTooltip": "복원",
|
||||
|
||||
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
|
||||
|
@ -184,7 +184,6 @@
|
|||
"editEntryDateDialogCopyField": "다른 날짜에서 지정",
|
||||
"editEntryDateDialogExtractFromTitle": "제목에서 추출",
|
||||
"editEntryDateDialogShift": "시간 이동",
|
||||
"editEntryDateDialogClear": "삭제",
|
||||
"editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜",
|
||||
"editEntryDateDialogTargetFieldsHeader": "수정할 필드",
|
||||
"editEntryDateDialogHours": "시간",
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
"nextTooltip": "Следующий",
|
||||
"showTooltip": "Показать",
|
||||
"hideTooltip": "Скрыть",
|
||||
"removeTooltip": "Удалить",
|
||||
"actionRemove": "Удалить",
|
||||
"resetButtonTooltip": "Сбросить",
|
||||
|
||||
"doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.",
|
||||
|
@ -180,7 +180,6 @@
|
|||
"editEntryDateDialogTitle": "Дата и время",
|
||||
"editEntryDateDialogExtractFromTitle": "Извлечь из названия",
|
||||
"editEntryDateDialogShift": "Сдвиг",
|
||||
"editEntryDateDialogClear": "Очистить",
|
||||
"editEntryDateDialogHours": "Часов",
|
||||
"editEntryDateDialogMinutes": "Минут",
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 {
|
||||
|
|
122
lib/model/entry_metadata_edition.dart
Normal file
122
lib/model/entry_metadata_edition.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
40
lib/services/metadata/xmp.dart
Normal file
40
lib/services/metadata/xmp.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
273
lib/utils/xmp_utils.dart
Normal 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 }
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
136
lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart
Normal file
136
lib/widgets/dialogs/entry_editors/edit_entry_rating_dialog.dart
Normal 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 }
|
|
@ -43,7 +43,7 @@ class _DrawerAlbumTabState extends State<DrawerAlbumTab> {
|
|||
onPressed: () {
|
||||
setState(() => widget.items.remove(album));
|
||||
},
|
||||
tooltip: context.l10n.removeTooltip,
|
||||
tooltip: context.l10n.actionRemove,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
@ -154,7 +154,7 @@ class _HiddenPaths extends StatelessWidget {
|
|||
onPressed: () {
|
||||
context.read<CollectionSource>().changeFilterVisibility({pathFilter}, true);
|
||||
},
|
||||
tooltip: context.l10n.removeTooltip,
|
||||
tooltip: context.l10n.actionRemove,
|
||||
),
|
||||
)),
|
||||
],
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
390
test/utils/xmp_utils_test.dart
Normal file
390
test/utils/xmp_utils_test.dart
Normal 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));
|
||||
});
|
||||
}
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue