#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) }
|
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||||
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
||||||
"setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) }
|
"editMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editMetadata) }
|
||||||
"setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) }
|
|
||||||
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
|
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
@ -99,12 +98,11 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setIptc(call: MethodCall, result: MethodChannel.Result) {
|
private fun editMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val iptc = call.argument<List<FieldMap>>("iptc")
|
val metadata = call.argument<FieldMap>("metadata")
|
||||||
val entryMap = call.argument<FieldMap>("entry")
|
val entryMap = call.argument<FieldMap>("entry")
|
||||||
val postEditScan = call.argument<Boolean>("postEditScan")
|
if (entryMap == null || metadata == null) {
|
||||||
if (entryMap == null || postEditScan == null) {
|
result.error("editMetadata-args", "failed because of missing arguments", null)
|
||||||
result.error("setIptc-args", "failed because of missing arguments", null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,48 +110,19 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val provider = getProvider(uri)
|
val provider = getProvider(uri)
|
||||||
if (provider == null) {
|
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
|
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 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)
|
override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata 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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -118,8 +118,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
|
||||||
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
|
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
|
||||||
val uuidDirCount = HashMap<String, Int>()
|
val uuidDirCount = HashMap<String, Int>()
|
||||||
val dirByName = metadata.directories.filter {
|
val dirByName = metadata.directories.filter {
|
||||||
it.tagCount > 0
|
it.tagCount > 0
|
||||||
|
@ -358,26 +358,22 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
return dirMap
|
return dirMap
|
||||||
}
|
}
|
||||||
|
|
||||||
// legend: ME=MetadataExtractor, EI=ExifInterface, MMR=MediaMetadataRetriever
|
|
||||||
// set `KEY_DATE_MILLIS` from these fields (by precedence):
|
// set `KEY_DATE_MILLIS` from these fields (by precedence):
|
||||||
// - ME / Exif / DATETIME_ORIGINAL
|
// - Exif / DATETIME_ORIGINAL
|
||||||
// - ME / Exif / DATETIME
|
// - Exif / DATETIME
|
||||||
// - EI / Exif / DATETIME_ORIGINAL
|
// - XMP / xmp:CreateDate
|
||||||
// - EI / Exif / DATETIME
|
// - XMP / photoshop:DateCreated
|
||||||
// - ME / XMP / xmp:CreateDate
|
// - PNG / TIME / LAST_MODIFICATION_TIME
|
||||||
// - ME / XMP / photoshop:DateCreated
|
// - Video / METADATA_KEY_DATE
|
||||||
// - ME / PNG / TIME / LAST_MODIFICATION_TIME
|
|
||||||
// - MMR / METADATA_KEY_DATE
|
|
||||||
// set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence):
|
// set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence):
|
||||||
// - ME / XMP / dc:title
|
// - XMP / dc:title
|
||||||
// - ME / XMP / dc:description
|
// - XMP / dc:description
|
||||||
// set `KEY_XMP_SUBJECTS` from these fields (by precedence):
|
// set `KEY_XMP_SUBJECTS` from these fields (by precedence):
|
||||||
// - ME / XMP / dc:subject
|
// - XMP / dc:subject
|
||||||
// - ME / IPTC / keywords
|
// - IPTC / keywords
|
||||||
// set `KEY_RATING` from these fields (by precedence):
|
// set `KEY_RATING` from these fields (by precedence):
|
||||||
// - ME / XMP / xmp:Rating
|
// - XMP / xmp:Rating
|
||||||
// - ME / XMP / MicrosoftPhoto:Rating
|
// - XMP / MicrosoftPhoto:Rating
|
||||||
// - ME / XMP / acdsee:rating
|
|
||||||
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
@ -412,7 +408,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
|
||||||
|
|
||||||
// File type
|
// File type
|
||||||
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
|
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
|
||||||
|
@ -437,13 +433,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
// EXIF
|
// EXIF
|
||||||
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
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)) {
|
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
|
||||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
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
|
val orientation = it
|
||||||
if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED
|
if (isFlippedForExifCode(orientation)) flags = flags or MASK_IS_FLIPPED
|
||||||
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation)
|
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation)
|
||||||
|
@ -486,9 +482,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
val standardRating = (percentRating / 25f).roundToInt() + 1
|
val standardRating = (percentRating / 25f).roundToInt() + 1
|
||||||
metadataMap[KEY_RATING] = standardRating
|
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)
|
// identification of panorama (aka photo sphere)
|
||||||
|
@ -676,10 +669,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
||||||
foundExif = true
|
foundExif = true
|
||||||
dir.getSafeRational(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
|
dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
|
||||||
dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME, saveExposureTime)
|
dir.getSafeRational(ExifDirectoryBase.TAG_EXPOSURE_TIME, saveExposureTime)
|
||||||
dir.getSafeRational(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator }
|
dir.getSafeRational(ExifDirectoryBase.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator }
|
||||||
dir.getSafeInt(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it }
|
dir.getSafeInt(ExifDirectoryBase.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
|
@ -14,7 +14,6 @@ object XMP {
|
||||||
|
|
||||||
// standard namespaces
|
// standard namespaces
|
||||||
// cf com.adobe.internal.xmp.XMPConst
|
// 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 DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
||||||
const val MICROSOFTPHOTO_SCHEMA_NS = "http://ns.microsoft.com/photo/1.0/"
|
const val MICROSOFTPHOTO_SCHEMA_NS = "http://ns.microsoft.com/photo/1.0/"
|
||||||
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/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/"
|
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/"
|
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_DESCRIPTION_PROP_NAME = "dc:description"
|
||||||
const val DC_SUBJECT_PROP_NAME = "dc:subject"
|
const val DC_SUBJECT_PROP_NAME = "dc:subject"
|
||||||
const val DC_TITLE_PROP_NAME = "dc:title"
|
const val DC_TITLE_PROP_NAME = "dc:title"
|
||||||
|
|
|
@ -800,63 +800,47 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setIptc(
|
fun editMetadata(
|
||||||
context: Context,
|
context: Context,
|
||||||
path: String,
|
path: String,
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
postEditScan: Boolean,
|
modifier: FieldMap,
|
||||||
callback: ImageOpCallback,
|
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(
|
if (modifier.containsKey("xmp")) {
|
||||||
context = context,
|
val xmp = modifier["xmp"] as Map<*, *>?
|
||||||
path = path,
|
if (xmp != null) {
|
||||||
uri = uri,
|
val coreXmp = xmp["xmp"] as String?
|
||||||
mimeType = mimeType,
|
val extendedXmp = xmp["extendedXmp"] as String?
|
||||||
callback = callback,
|
if (!editXmp(
|
||||||
iptc = iptc,
|
context = context,
|
||||||
)
|
path = path,
|
||||||
|
uri = uri,
|
||||||
if (success) {
|
mimeType = mimeType,
|
||||||
if (postEditScan) {
|
callback = callback,
|
||||||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
coreXmp = coreXmp,
|
||||||
} else {
|
extendedXmp = extendedXmp,
|
||||||
callback.onSuccess(HashMap())
|
)
|
||||||
|
) 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 newFields = HashMap<String, Any?>()
|
||||||
|
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeMetadataTypes(
|
fun removeMetadataTypes(
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
"nextTooltip": "Nächste",
|
"nextTooltip": "Nächste",
|
||||||
"showTooltip": "Anzeigen",
|
"showTooltip": "Anzeigen",
|
||||||
"hideTooltip": "Ausblenden",
|
"hideTooltip": "Ausblenden",
|
||||||
"removeTooltip": "Entfernen",
|
"actionRemove": "Entfernen",
|
||||||
"resetButtonTooltip": "Zurücksetzen",
|
"resetButtonTooltip": "Zurücksetzen",
|
||||||
|
|
||||||
"doubleBackExitMessage": "Tippen Sie zum Verlassen erneut auf „Zurück“.",
|
"doubleBackExitMessage": "Tippen Sie zum Verlassen erneut auf „Zurück“.",
|
||||||
|
@ -180,7 +180,6 @@
|
||||||
"editEntryDateDialogTitle": "Datum & Uhrzeit",
|
"editEntryDateDialogTitle": "Datum & Uhrzeit",
|
||||||
"editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel",
|
"editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel",
|
||||||
"editEntryDateDialogShift": "Verschieben",
|
"editEntryDateDialogShift": "Verschieben",
|
||||||
"editEntryDateDialogClear": "Aufräumen",
|
|
||||||
"editEntryDateDialogHours": "Stunden",
|
"editEntryDateDialogHours": "Stunden",
|
||||||
"editEntryDateDialogMinutes": "Minuten",
|
"editEntryDateDialogMinutes": "Minuten",
|
||||||
|
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
"nextTooltip": "Next",
|
"nextTooltip": "Next",
|
||||||
"showTooltip": "Show",
|
"showTooltip": "Show",
|
||||||
"hideTooltip": "Hide",
|
"hideTooltip": "Hide",
|
||||||
"removeTooltip": "Remove",
|
"actionRemove": "Remove",
|
||||||
"resetButtonTooltip": "Reset",
|
"resetButtonTooltip": "Reset",
|
||||||
|
|
||||||
"doubleBackExitMessage": "Tap “back” again to exit.",
|
"doubleBackExitMessage": "Tap “back” again to exit.",
|
||||||
|
@ -88,6 +88,7 @@
|
||||||
"videoActionSettings": "Settings",
|
"videoActionSettings": "Settings",
|
||||||
|
|
||||||
"entryInfoActionEditDate": "Edit date & time",
|
"entryInfoActionEditDate": "Edit date & time",
|
||||||
|
"entryInfoActionEditRating": "Edit rating",
|
||||||
"entryInfoActionEditTags": "Edit tags",
|
"entryInfoActionEditTags": "Edit tags",
|
||||||
"entryInfoActionRemoveMetadata": "Remove metadata",
|
"entryInfoActionRemoveMetadata": "Remove metadata",
|
||||||
|
|
||||||
|
@ -279,12 +280,13 @@
|
||||||
"editEntryDateDialogCopyField": "Set from other date",
|
"editEntryDateDialogCopyField": "Set from other date",
|
||||||
"editEntryDateDialogExtractFromTitle": "Extract from title",
|
"editEntryDateDialogExtractFromTitle": "Extract from title",
|
||||||
"editEntryDateDialogShift": "Shift",
|
"editEntryDateDialogShift": "Shift",
|
||||||
"editEntryDateDialogClear": "Clear",
|
|
||||||
"editEntryDateDialogSourceFileModifiedDate": "File modified date",
|
"editEntryDateDialogSourceFileModifiedDate": "File modified date",
|
||||||
"editEntryDateDialogTargetFieldsHeader": "Fields to modify",
|
"editEntryDateDialogTargetFieldsHeader": "Fields to modify",
|
||||||
"editEntryDateDialogHours": "Hours",
|
"editEntryDateDialogHours": "Hours",
|
||||||
"editEntryDateDialogMinutes": "Minutes",
|
"editEntryDateDialogMinutes": "Minutes",
|
||||||
|
|
||||||
|
"editEntryRatingDialogTitle": "Rating",
|
||||||
|
|
||||||
"removeEntryMetadataDialogTitle": "Metadata Removal",
|
"removeEntryMetadataDialogTitle": "Metadata Removal",
|
||||||
"removeEntryMetadataDialogMore": "More",
|
"removeEntryMetadataDialogMore": "More",
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
"nextTooltip": "Suivant",
|
"nextTooltip": "Suivant",
|
||||||
"showTooltip": "Afficher",
|
"showTooltip": "Afficher",
|
||||||
"hideTooltip": "Masquer",
|
"hideTooltip": "Masquer",
|
||||||
"removeTooltip": "Supprimer",
|
"actionRemove": "Supprimer",
|
||||||
"resetButtonTooltip": "Réinitialiser",
|
"resetButtonTooltip": "Réinitialiser",
|
||||||
|
|
||||||
"doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.",
|
"doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.",
|
||||||
|
@ -184,7 +184,6 @@
|
||||||
"editEntryDateDialogCopyField": "Copier d'une autre date",
|
"editEntryDateDialogCopyField": "Copier d'une autre date",
|
||||||
"editEntryDateDialogExtractFromTitle": "Extraire du titre",
|
"editEntryDateDialogExtractFromTitle": "Extraire du titre",
|
||||||
"editEntryDateDialogShift": "Décaler",
|
"editEntryDateDialogShift": "Décaler",
|
||||||
"editEntryDateDialogClear": "Effacer",
|
|
||||||
"editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier",
|
"editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier",
|
||||||
"editEntryDateDialogTargetFieldsHeader": "Champs à modifier",
|
"editEntryDateDialogTargetFieldsHeader": "Champs à modifier",
|
||||||
"editEntryDateDialogHours": "Heures",
|
"editEntryDateDialogHours": "Heures",
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
"nextTooltip": "다음",
|
"nextTooltip": "다음",
|
||||||
"showTooltip": "보기",
|
"showTooltip": "보기",
|
||||||
"hideTooltip": "숨기기",
|
"hideTooltip": "숨기기",
|
||||||
"removeTooltip": "제거",
|
"actionRemove": "제거",
|
||||||
"resetButtonTooltip": "복원",
|
"resetButtonTooltip": "복원",
|
||||||
|
|
||||||
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
|
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
|
||||||
|
@ -184,7 +184,6 @@
|
||||||
"editEntryDateDialogCopyField": "다른 날짜에서 지정",
|
"editEntryDateDialogCopyField": "다른 날짜에서 지정",
|
||||||
"editEntryDateDialogExtractFromTitle": "제목에서 추출",
|
"editEntryDateDialogExtractFromTitle": "제목에서 추출",
|
||||||
"editEntryDateDialogShift": "시간 이동",
|
"editEntryDateDialogShift": "시간 이동",
|
||||||
"editEntryDateDialogClear": "삭제",
|
|
||||||
"editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜",
|
"editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜",
|
||||||
"editEntryDateDialogTargetFieldsHeader": "수정할 필드",
|
"editEntryDateDialogTargetFieldsHeader": "수정할 필드",
|
||||||
"editEntryDateDialogHours": "시간",
|
"editEntryDateDialogHours": "시간",
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
"nextTooltip": "Следующий",
|
"nextTooltip": "Следующий",
|
||||||
"showTooltip": "Показать",
|
"showTooltip": "Показать",
|
||||||
"hideTooltip": "Скрыть",
|
"hideTooltip": "Скрыть",
|
||||||
"removeTooltip": "Удалить",
|
"actionRemove": "Удалить",
|
||||||
"resetButtonTooltip": "Сбросить",
|
"resetButtonTooltip": "Сбросить",
|
||||||
|
|
||||||
"doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.",
|
"doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.",
|
||||||
|
@ -180,7 +180,6 @@
|
||||||
"editEntryDateDialogTitle": "Дата и время",
|
"editEntryDateDialogTitle": "Дата и время",
|
||||||
"editEntryDateDialogExtractFromTitle": "Извлечь из названия",
|
"editEntryDateDialogExtractFromTitle": "Извлечь из названия",
|
||||||
"editEntryDateDialogShift": "Сдвиг",
|
"editEntryDateDialogShift": "Сдвиг",
|
||||||
"editEntryDateDialogClear": "Очистить",
|
|
||||||
"editEntryDateDialogHours": "Часов",
|
"editEntryDateDialogHours": "Часов",
|
||||||
"editEntryDateDialogMinutes": "Минут",
|
"editEntryDateDialogMinutes": "Минут",
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
|
||||||
enum EntryInfoAction {
|
enum EntryInfoAction {
|
||||||
// general
|
// general
|
||||||
editDate,
|
editDate,
|
||||||
|
editRating,
|
||||||
editTags,
|
editTags,
|
||||||
removeMetadata,
|
removeMetadata,
|
||||||
// motion photo
|
// motion photo
|
||||||
|
@ -14,6 +15,7 @@ enum EntryInfoAction {
|
||||||
class EntryInfoActions {
|
class EntryInfoActions {
|
||||||
static const all = [
|
static const all = [
|
||||||
EntryInfoAction.editDate,
|
EntryInfoAction.editDate,
|
||||||
|
EntryInfoAction.editRating,
|
||||||
EntryInfoAction.editTags,
|
EntryInfoAction.editTags,
|
||||||
EntryInfoAction.removeMetadata,
|
EntryInfoAction.removeMetadata,
|
||||||
EntryInfoAction.viewMotionPhotoVideo,
|
EntryInfoAction.viewMotionPhotoVideo,
|
||||||
|
@ -26,6 +28,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
return context.l10n.entryInfoActionEditDate;
|
return context.l10n.entryInfoActionEditDate;
|
||||||
|
case EntryInfoAction.editRating:
|
||||||
|
return context.l10n.entryInfoActionEditRating;
|
||||||
case EntryInfoAction.editTags:
|
case EntryInfoAction.editTags:
|
||||||
return context.l10n.entryInfoActionEditTags;
|
return context.l10n.entryInfoActionEditTags;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
|
@ -45,6 +49,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
return AIcons.date;
|
return AIcons.date;
|
||||||
|
case EntryInfoAction.editRating:
|
||||||
|
return AIcons.rating;
|
||||||
case EntryInfoAction.editTags:
|
case EntryInfoAction.editTags:
|
||||||
return AIcons.addTag;
|
return AIcons.addTag;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
|
|
|
@ -25,6 +25,7 @@ enum EntrySetAction {
|
||||||
rotateCW,
|
rotateCW,
|
||||||
flip,
|
flip,
|
||||||
editDate,
|
editDate,
|
||||||
|
editRating,
|
||||||
editTags,
|
editTags,
|
||||||
removeMetadata,
|
removeMetadata,
|
||||||
}
|
}
|
||||||
|
@ -101,6 +102,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
||||||
return context.l10n.entryActionFlip;
|
return context.l10n.entryActionFlip;
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
return context.l10n.entryInfoActionEditDate;
|
return context.l10n.entryInfoActionEditDate;
|
||||||
|
case EntrySetAction.editRating:
|
||||||
|
return context.l10n.entryInfoActionEditRating;
|
||||||
case EntrySetAction.editTags:
|
case EntrySetAction.editTags:
|
||||||
return context.l10n.entryInfoActionEditTags;
|
return context.l10n.entryInfoActionEditTags;
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
|
@ -155,6 +158,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
||||||
return AIcons.flip;
|
return AIcons.flip;
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
return AIcons.date;
|
return AIcons.date;
|
||||||
|
case EntrySetAction.editRating:
|
||||||
|
return AIcons.rating;
|
||||||
case EntrySetAction.editTags:
|
case EntrySetAction.editTags:
|
||||||
return AIcons.addTag;
|
return AIcons.addTag;
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
|
|
|
@ -239,6 +239,8 @@ class AvesEntry {
|
||||||
|
|
||||||
bool get canEditDate => canEdit && canEditExif;
|
bool get canEditDate => canEdit && canEditExif;
|
||||||
|
|
||||||
|
bool get canEditRating => canEdit && canEditXmp;
|
||||||
|
|
||||||
bool get canEditTags => canEdit && canEditXmp;
|
bool get canEditTags => canEdit && canEditXmp;
|
||||||
|
|
||||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||||
|
@ -709,7 +711,7 @@ class AvesEntry {
|
||||||
break;
|
break;
|
||||||
case DateEditAction.setCustom:
|
case DateEditAction.setCustom:
|
||||||
case DateEditAction.shift:
|
case DateEditAction.shift:
|
||||||
case DateEditAction.clear:
|
case DateEditAction.remove:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
final newFields = await metadataEditService.editDate(this, modifier);
|
final newFields = await metadataEditService.editDate(this, modifier);
|
||||||
|
@ -733,10 +735,14 @@ class AvesEntry {
|
||||||
final metadataDate = catalogMetadata?.dateMillis;
|
final metadataDate = catalogMetadata?.dateMillis;
|
||||||
if (metadataDate != null && metadataDate > 0) return {};
|
if (metadataDate != null && metadataDate > 0) return {};
|
||||||
|
|
||||||
return await editDate(DateModifier.copyField(
|
if (canEditExif) {
|
||||||
const {MetadataField.exifDateOriginal},
|
return await editDate(DateModifier.copyField(
|
||||||
DateFieldSource.fileModifiedDate,
|
const {MetadataField.exifDateOriginal},
|
||||||
));
|
DateFieldSource.fileModifiedDate,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// TODO TLAD [metadata] set XMP / xmp:CreateDate
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
|
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);
|
return DateModifier._private(DateEditAction.shift, fields, shiftMinutes: shiftMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
factory DateModifier.clear(Set<MetadataField> fields) {
|
factory DateModifier.remove(Set<MetadataField> fields) {
|
||||||
return DateModifier._private(DateEditAction.clear, fields);
|
return DateModifier._private(DateEditAction.remove, fields);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ enum DateEditAction {
|
||||||
copyField,
|
copyField,
|
||||||
extractFromTitle,
|
extractFromTitle,
|
||||||
shift,
|
shift,
|
||||||
clear,
|
remove,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DateFieldSource {
|
enum DateFieldSource {
|
||||||
|
@ -65,7 +65,7 @@ class MetadataTypes {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ExtraMetadataType on MetadataType {
|
extension ExtraMetadataType on MetadataType {
|
||||||
// match `ExifInterface` directory names
|
// match `metadata-extractor` directory names
|
||||||
String getText() {
|
String getText() {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case MetadataType.comment:
|
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 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
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/date_modifier.dart';
|
||||||
import 'package:aves/model/metadata/enums.dart';
|
import 'package:aves/model/metadata/enums.dart';
|
||||||
import 'package:aves/services/common/services.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>> editDate(AvesEntry entry, DateModifier modifier);
|
||||||
|
|
||||||
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan});
|
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> modifier);
|
||||||
|
|
||||||
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp);
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
|
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
|
||||||
}
|
}
|
||||||
|
@ -91,29 +88,11 @@ class PlatformMetadataEditService implements MetadataEditService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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 {
|
try {
|
||||||
final result = await platform.invokeMethod('setIptc', <String, dynamic>{
|
final result = await platform.invokeMethod('editMetadata', <String, dynamic>{
|
||||||
'entry': _toPlatformEntryMap(entry),
|
'entry': _toPlatformEntryMap(entry),
|
||||||
'iptc': iptc,
|
'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)),
|
||||||
'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,
|
|
||||||
});
|
});
|
||||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||||
} on PlatformException catch (e, stack) {
|
} on PlatformException catch (e, stack) {
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import 'package:aves/model/entry.dart';
|
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/catalog.dart';
|
||||||
import 'package:aves/model/metadata/enums.dart';
|
import 'package:aves/model/metadata/enums.dart';
|
||||||
import 'package:aves/model/metadata/overlay.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/model/panorama.dart';
|
||||||
import 'package:aves/services/common/service_policy.dart';
|
import 'package:aves/services/common/service_policy.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
|
import 'package:aves/services/metadata/xmp.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.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 locationOff = Icons.location_off_outlined;
|
||||||
static const IconData mainStorage = Icons.smartphone_outlined;
|
static const IconData mainStorage = Icons.smartphone_outlined;
|
||||||
static const IconData privacy = MdiIcons.shieldAccountOutline;
|
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 ratingRejected = MdiIcons.starMinusOutline;
|
||||||
static const IconData ratingUnrated = MdiIcons.starOffOutline;
|
static const IconData ratingUnrated = MdiIcons.starOffOutline;
|
||||||
static const IconData raw = Icons.raw_on_outlined;
|
static const IconData raw = Icons.raw_on_outlined;
|
||||||
|
@ -113,7 +115,6 @@ class AIcons {
|
||||||
static const IconData geo = Icons.language_outlined;
|
static const IconData geo = Icons.language_outlined;
|
||||||
static const IconData motionPhoto = Icons.motion_photos_on_outlined;
|
static const IconData motionPhoto = Icons.motion_photos_on_outlined;
|
||||||
static const IconData multiPage = Icons.burst_mode_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 threeSixty = Icons.threesixty_outlined;
|
||||||
static const IconData videoThumb = Icons.play_circle_outline;
|
static const IconData videoThumb = Icons.play_circle_outline;
|
||||||
static const IconData selected = Icons.check_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),
|
_buildRotateAndFlipMenuItems(context, canApply: canApply),
|
||||||
...[
|
...[
|
||||||
EntrySetAction.editDate,
|
EntrySetAction.editDate,
|
||||||
|
EntrySetAction.editRating,
|
||||||
EntrySetAction.editTags,
|
EntrySetAction.editTags,
|
||||||
EntrySetAction.removeMetadata,
|
EntrySetAction.removeMetadata,
|
||||||
].map((action) => _toMenuItem(action, enabled: canApply(action))),
|
].map((action) => _toMenuItem(action, enabled: canApply(action))),
|
||||||
|
@ -427,6 +428,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
case EntrySetAction.rotateCW:
|
case EntrySetAction.rotateCW:
|
||||||
case EntrySetAction.flip:
|
case EntrySetAction.flip:
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
|
case EntrySetAction.editRating:
|
||||||
case EntrySetAction.editTags:
|
case EntrySetAction.editTags:
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
_actionDelegate.onActionSelected(context, action);
|
_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/actions/move_type.dart';
|
||||||
import 'package:aves/model/device.dart';
|
import 'package:aves/model/device.dart';
|
||||||
import 'package:aves/model/entry.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/album.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/highlight.dart';
|
import 'package:aves/model/highlight.dart';
|
||||||
|
@ -77,6 +77,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
case EntrySetAction.rotateCW:
|
case EntrySetAction.rotateCW:
|
||||||
case EntrySetAction.flip:
|
case EntrySetAction.flip:
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
|
case EntrySetAction.editRating:
|
||||||
case EntrySetAction.editTags:
|
case EntrySetAction.editTags:
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
return appMode == AppMode.main && isSelecting;
|
return appMode == AppMode.main && isSelecting;
|
||||||
|
@ -118,6 +119,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
case EntrySetAction.rotateCW:
|
case EntrySetAction.rotateCW:
|
||||||
case EntrySetAction.flip:
|
case EntrySetAction.flip:
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
|
case EntrySetAction.editRating:
|
||||||
case EntrySetAction.editTags:
|
case EntrySetAction.editTags:
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
return hasSelection;
|
return hasSelection;
|
||||||
|
@ -177,6 +179,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
_editDate(context);
|
_editDate(context);
|
||||||
break;
|
break;
|
||||||
|
case EntrySetAction.editRating:
|
||||||
|
_editRating(context);
|
||||||
|
break;
|
||||||
case EntrySetAction.editTags:
|
case EntrySetAction.editTags:
|
||||||
_editTags(context);
|
_editTags(context);
|
||||||
break;
|
break;
|
||||||
|
@ -513,6 +518,19 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier));
|
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 {
|
Future<void> _editTags(BuildContext context) async {
|
||||||
final selection = context.read<Selection<AvesEntry>>();
|
final selection = context.read<Selection<AvesEntry>>();
|
||||||
final selectedItems = _getExpandedSelectedItems(selection);
|
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/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.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_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/edit_entry_tags_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart';
|
import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -22,6 +23,18 @@ mixin EntryEditorMixin {
|
||||||
return modifier;
|
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 {
|
Future<Map<AvesEntry, Set<String>>?> selectTags(BuildContext context, Set<AvesEntry> entries) async {
|
||||||
if (entries.isEmpty) return null;
|
if (entries.isEmpty) return null;
|
||||||
|
|
||||||
|
|
|
@ -20,12 +20,12 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final children = [
|
final children = [
|
||||||
if (entry.hasGps && context.select<GridThemeData, bool>((t) => t.showLocation)) const GpsIcon(),
|
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)
|
if (entry.isVideo)
|
||||||
VideoIcon(entry: entry)
|
VideoIcon(entry: entry)
|
||||||
else if (entry.isAnimated)
|
else if (entry.isAnimated)
|
||||||
const AnimatedImageIcon()
|
const AnimatedImageIcon()
|
||||||
else ...[
|
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.isRaw && context.select<GridThemeData, bool>((t) => t.showRaw)) const RawIcon(),
|
||||||
if (entry.isGeotiff) const GeotiffIcon(),
|
if (entry.isGeotiff) const GeotiffIcon(),
|
||||||
if (entry.is360) const SphericalImageIcon(),
|
if (entry.is360) const SphericalImageIcon(),
|
||||||
|
|
|
@ -277,8 +277,8 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
return l10n.editEntryDateDialogExtractFromTitle;
|
return l10n.editEntryDateDialogExtractFromTitle;
|
||||||
case DateEditAction.shift:
|
case DateEditAction.shift:
|
||||||
return l10n.editEntryDateDialogShift;
|
return l10n.editEntryDateDialogShift;
|
||||||
case DateEditAction.clear:
|
case DateEditAction.remove:
|
||||||
return l10n.editEntryDateDialogClear;
|
return l10n.actionRemove;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -347,8 +347,8 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
case DateEditAction.shift:
|
case DateEditAction.shift:
|
||||||
final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1);
|
final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1);
|
||||||
return DateModifier.shift(_fields, shiftTotalMinutes);
|
return DateModifier.shift(_fields, shiftTotalMinutes);
|
||||||
case DateEditAction.clear:
|
case DateEditAction.remove:
|
||||||
return DateModifier.clear(_fields);
|
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: () {
|
onPressed: () {
|
||||||
setState(() => widget.items.remove(album));
|
setState(() => widget.items.remove(album));
|
||||||
},
|
},
|
||||||
tooltip: context.l10n.removeTooltip,
|
tooltip: context.l10n.actionRemove,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -154,7 +154,7 @@ class _HiddenPaths extends StatelessWidget {
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<CollectionSource>().changeFilterVisibility({pathFilter}, true);
|
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/entry_info_actions.dart';
|
||||||
import 'package:aves/model/actions/events.dart';
|
import 'package:aves/model/actions/events.dart';
|
||||||
import 'package:aves/model/entry.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/entry_editor.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||||
|
@ -25,6 +25,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
||||||
switch (action) {
|
switch (action) {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
|
case EntryInfoAction.editRating:
|
||||||
case EntryInfoAction.editTags:
|
case EntryInfoAction.editTags:
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
return true;
|
return true;
|
||||||
|
@ -39,6 +40,8 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
return entry.canEditDate;
|
return entry.canEditDate;
|
||||||
|
case EntryInfoAction.editRating:
|
||||||
|
return entry.canEditRating;
|
||||||
case EntryInfoAction.editTags:
|
case EntryInfoAction.editTags:
|
||||||
return entry.canEditTags;
|
return entry.canEditTags;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
|
@ -56,6 +59,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
await _editDate(context);
|
await _editDate(context);
|
||||||
break;
|
break;
|
||||||
|
case EntryInfoAction.editRating:
|
||||||
|
await _editRating(context);
|
||||||
|
break;
|
||||||
case EntryInfoAction.editTags:
|
case EntryInfoAction.editTags:
|
||||||
await _editTags(context);
|
await _editTags(context);
|
||||||
break;
|
break;
|
||||||
|
@ -77,6 +83,13 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
||||||
await edit(context, () => entry.editDate(modifier));
|
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 {
|
Future<void> _editTags(BuildContext context) async {
|
||||||
final newTagsByEntry = await selectTags(context, {entry});
|
final newTagsByEntry = await selectTags(context, {entry});
|
||||||
if (newTagsByEntry == null) return;
|
if (newTagsByEntry == null) return;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:aves/ref/brand_colors.dart';
|
import 'package:aves/ref/brand_colors.dart';
|
||||||
import 'package:aves/ref/xmp.dart';
|
|
||||||
import 'package:aves/utils/constants.dart';
|
import 'package:aves/utils/constants.dart';
|
||||||
import 'package:aves/utils/string_utils.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/common/identity/highlight_title.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/crs.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;
|
Map<String, String> get buildProps => rawProps;
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/ref/xmp.dart';
|
|
||||||
import 'package:aves/utils/color_utils.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/common/identity/aves_expansion_tile.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
||||||
import 'package:collection/collection.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": [
|
"de": [
|
||||||
|
"entryInfoActionEditRating",
|
||||||
"filterRatingUnratedLabel",
|
"filterRatingUnratedLabel",
|
||||||
"filterRatingRejectedLabel",
|
"filterRatingRejectedLabel",
|
||||||
"missingSystemFilePickerDialogTitle",
|
"missingSystemFilePickerDialogTitle",
|
||||||
|
@ -8,22 +9,28 @@
|
||||||
"editEntryDateDialogCopyField",
|
"editEntryDateDialogCopyField",
|
||||||
"editEntryDateDialogSourceFileModifiedDate",
|
"editEntryDateDialogSourceFileModifiedDate",
|
||||||
"editEntryDateDialogTargetFieldsHeader",
|
"editEntryDateDialogTargetFieldsHeader",
|
||||||
|
"editEntryRatingDialogTitle",
|
||||||
"collectionSortRating",
|
"collectionSortRating",
|
||||||
"searchSectionRating",
|
"searchSectionRating",
|
||||||
"settingsThumbnailShowRating"
|
"settingsThumbnailShowRating"
|
||||||
],
|
],
|
||||||
|
|
||||||
"fr": [
|
"fr": [
|
||||||
|
"entryInfoActionEditRating",
|
||||||
"missingSystemFilePickerDialogTitle",
|
"missingSystemFilePickerDialogTitle",
|
||||||
"missingSystemFilePickerDialogMessage"
|
"missingSystemFilePickerDialogMessage",
|
||||||
|
"editEntryRatingDialogTitle"
|
||||||
],
|
],
|
||||||
|
|
||||||
"ko": [
|
"ko": [
|
||||||
|
"entryInfoActionEditRating",
|
||||||
"missingSystemFilePickerDialogTitle",
|
"missingSystemFilePickerDialogTitle",
|
||||||
"missingSystemFilePickerDialogMessage"
|
"missingSystemFilePickerDialogMessage",
|
||||||
|
"editEntryRatingDialogTitle"
|
||||||
],
|
],
|
||||||
|
|
||||||
"ru": [
|
"ru": [
|
||||||
|
"entryInfoActionEditRating",
|
||||||
"filterRatingUnratedLabel",
|
"filterRatingUnratedLabel",
|
||||||
"filterRatingRejectedLabel",
|
"filterRatingRejectedLabel",
|
||||||
"missingSystemFilePickerDialogTitle",
|
"missingSystemFilePickerDialogTitle",
|
||||||
|
@ -32,6 +39,7 @@
|
||||||
"editEntryDateDialogCopyField",
|
"editEntryDateDialogCopyField",
|
||||||
"editEntryDateDialogSourceFileModifiedDate",
|
"editEntryDateDialogSourceFileModifiedDate",
|
||||||
"editEntryDateDialogTargetFieldsHeader",
|
"editEntryDateDialogTargetFieldsHeader",
|
||||||
|
"editEntryRatingDialogTitle",
|
||||||
"collectionSortRating",
|
"collectionSortRating",
|
||||||
"searchSectionRating",
|
"searchSectionRating",
|
||||||
"settingsThumbnailShowRating"
|
"settingsThumbnailShowRating"
|
||||||
|
|
Loading…
Reference in a new issue