#301 info: edit description

This commit is contained in:
Thibault Deckers 2022-08-17 20:03:22 +02:00
parent 1d4d0307d7
commit c015c71fa9
33 changed files with 342 additions and 70 deletions

View file

@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
- Viewer: optional gesture to show previous/next item
- Albums / Countries / Tags: live title filter
- option to hide confirmation message after moving items to the bin
- Collection / Info: edit description via Exif / IPTC / XMP
### Changed

View file

@ -106,6 +106,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentResolverProp) }
"getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentResolverProp) }
"getDate" -> ioScope.launch { safe(call, result, ::getDate) }
"getDescription" -> ioScope.launch { safe(call, result, ::getDescription) }
else -> result.notImplemented()
}
}
@ -409,9 +410,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// - XMP / photoshop:DateCreated
// - PNG / TIME / LAST_MODIFICATION_TIME
// - Video / METADATA_KEY_DATE
// set `KEY_XMP_TITLE_DESCRIPTION` from these fields (by precedence):
// set `KEY_XMP_TITLE` from this field:
// - XMP / dc:title
// - XMP / dc:description
// set `KEY_XMP_SUBJECTS` from these fields (by precedence):
// - XMP / dc:subject
// - IPTC / keywords
@ -514,10 +514,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.DC_SUBJECT_PROP_NAME, it).value }
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR)
}
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_DESCRIPTION_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
}
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
xmpMeta.getSafeDateMillis(XMP.XMP_SCHEMA_NS, XMP.XMP_CREATE_DATE_PROP_NAME) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
@ -1052,6 +1049,58 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(dateMillis)
}
// return description from these fields (by precedence):
// - XMP / dc:description
// - IPTC / caption-abstract
// - Exif / ImageDescription
private fun getDescription(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getDescription-args", "missing arguments", null)
return
}
var description: String? = null
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = MetadataExtractorHelper.safeRead(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
try {
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.DC_DESCRIPTION_PROP_NAME)) {
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.DC_DESCRIPTION_PROP_NAME) { description = it }
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
if (description == null) {
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
dir.getSafeString(IptcDirectory.TAG_CAPTION) { description = it }
}
}
if (description == null) {
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
dir.getSafeString(ExifIFD0Directory.TAG_IMAGE_DESCRIPTION) { description = it }
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: AssertionError) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
result.success(description)
}
companion object {
private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>()
const val CHANNEL = "deckers.thibault/aves/metadata_fetch"
@ -1100,7 +1149,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private const val KEY_LATITUDE = "latitude"
private const val KEY_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
private const val KEY_XMP_TITLE = "xmpTitleDescription"
private const val KEY_RATING = "rating"
private const val MASK_IS_ANIMATED = 1 shl 0

View file

@ -28,8 +28,8 @@ object XMP {
const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"
const val DC_DESCRIPTION_PROP_NAME = "dc:description"
const val DC_SUBJECT_PROP_NAME = "dc:subject"
const val DC_DESCRIPTION_PROP_NAME = "dc:description"
const val DC_TITLE_PROP_NAME = "dc:title"
const val MS_RATING_PROP_NAME = "MicrosoftPhoto:Rating"
const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated"

View file

@ -238,6 +238,8 @@
"renameEntryDialogLabel": "Neuer Name",
"editEntryDialogTargetFieldsHeader": "Zu ändernde Felder",
"editEntryDateDialogTitle": "Datum & Uhrzeit",
"editEntryDateDialogSetCustom": "Datum einstellen",
"editEntryDateDialogCopyField": "Von anderem Datum kopieren",
@ -245,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel",
"editEntryDateDialogShift": "Verschieben",
"editEntryDateDialogSourceFileModifiedDate": "Änderungsdatum der Datei",
"editEntryDateDialogTargetFieldsHeader": "Zu ändernde Felder",
"editEntryDateDialogHours": "Stunden",
"editEntryDateDialogMinutes": "Minuten",

View file

@ -115,6 +115,7 @@
"entryInfoActionEditDate": "Edit date & time",
"entryInfoActionEditLocation": "Edit location",
"entryInfoActionEditDescription": "Edit description",
"entryInfoActionEditRating": "Edit rating",
"entryInfoActionEditTags": "Edit tags",
"entryInfoActionRemoveMetadata": "Remove metadata",
@ -368,6 +369,8 @@
"renameEntryDialogLabel": "New name",
"editEntryDialogTargetFieldsHeader": "Fields to modify",
"editEntryDateDialogTitle": "Date & Time",
"editEntryDateDialogSetCustom": "Set custom date",
"editEntryDateDialogCopyField": "Copy from other date",
@ -375,7 +378,6 @@
"editEntryDateDialogExtractFromTitle": "Extract from title",
"editEntryDateDialogShift": "Shift",
"editEntryDateDialogSourceFileModifiedDate": "File modified date",
"editEntryDateDialogTargetFieldsHeader": "Fields to modify",
"editEntryDateDialogHours": "Hours",
"editEntryDateDialogMinutes": "Minutes",
@ -386,6 +388,8 @@
"locationPickerUseThisLocationButton": "Use this location",
"editEntryDescriptionDialogTitle": "Description",
"editEntryRatingDialogTitle": "Rating",
"removeEntryMetadataDialogTitle": "Metadata Removal",

View file

@ -238,6 +238,8 @@
"renameEntryDialogLabel": "Renombrar",
"editEntryDialogTargetFieldsHeader": "Campos a modificar",
"editEntryDateDialogTitle": "Fecha y hora",
"editEntryDateDialogSetCustom": "Establecer fecha personalizada",
"editEntryDateDialogCopyField": "Copiar de otra fecha",
@ -245,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "Extraer del título",
"editEntryDateDialogShift": "Cambiar",
"editEntryDateDialogSourceFileModifiedDate": "Fecha de modificación del archivo",
"editEntryDateDialogTargetFieldsHeader": "Campos a modificar",
"editEntryDateDialogHours": "Horas",
"editEntryDateDialogMinutes": "Minutos",

View file

@ -238,6 +238,8 @@
"renameEntryDialogLabel": "Nouveau nom",
"editEntryDialogTargetFieldsHeader": "Champs à modifier",
"editEntryDateDialogTitle": "Date & Heure",
"editEntryDateDialogSetCustom": "Régler une date personnalisée",
"editEntryDateDialogCopyField": "Copier dune autre date",
@ -245,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "Extraire du titre",
"editEntryDateDialogShift": "Décaler",
"editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier",
"editEntryDateDialogTargetFieldsHeader": "Champs à modifier",
"editEntryDateDialogHours": "Heures",
"editEntryDateDialogMinutes": "Minutes",

View file

@ -238,6 +238,8 @@
"renameEntryDialogLabel": "Nama baru",
"editEntryDialogTargetFieldsHeader": "Bidang untuk dimodifikasikan",
"editEntryDateDialogTitle": "Tanggal & Waktu",
"editEntryDateDialogSetCustom": "Atur tanggal khusus",
"editEntryDateDialogCopyField": "Salin dari tanggal lain",
@ -245,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "Ekstrak dari judul",
"editEntryDateDialogShift": "Geser",
"editEntryDateDialogSourceFileModifiedDate": "Tanggal modifikasi file",
"editEntryDateDialogTargetFieldsHeader": "Bidang untuk dimodifikasikan",
"editEntryDateDialogHours": "Jam",
"editEntryDateDialogMinutes": "Menit",

View file

@ -238,6 +238,8 @@
"renameEntryDialogLabel": "Nuovo nome",
"editEntryDialogTargetFieldsHeader": "Campi da modificare",
"editEntryDateDialogTitle": "Data e ora",
"editEntryDateDialogSetCustom": "Imposta data personalizzata",
"editEntryDateDialogCopyField": "Copia da unaltra data",
@ -245,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "Estrai dal titolo",
"editEntryDateDialogShift": "Turno",
"editEntryDateDialogSourceFileModifiedDate": "Data di modifica del file",
"editEntryDateDialogTargetFieldsHeader": "Campi da modificare",
"editEntryDateDialogHours": "Ore",
"editEntryDateDialogMinutes": "Minuti",

View file

@ -238,6 +238,8 @@
"renameEntryDialogLabel": "新しい名前",
"editEntryDialogTargetFieldsHeader": "更新するフィールド",
"editEntryDateDialogTitle": "日時",
"editEntryDateDialogSetCustom": "日を設定する",
"editEntryDateDialogCopyField": "他の日からコピーする",
@ -245,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "タイトルから抽出する",
"editEntryDateDialogShift": "シフト",
"editEntryDateDialogSourceFileModifiedDate": "ファイル更新日",
"editEntryDateDialogTargetFieldsHeader": "更新するフィールド",
"editEntryDateDialogHours": "時",
"editEntryDateDialogMinutes": "分",

View file

@ -238,6 +238,8 @@
"renameEntryDialogLabel": "이름",
"editEntryDialogTargetFieldsHeader": "수정할 필드",
"editEntryDateDialogTitle": "날짜 및 시간",
"editEntryDateDialogSetCustom": "지정 날짜로 편집",
"editEntryDateDialogCopyField": "다른 날짜에서 지정",
@ -245,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "제목에서 추출",
"editEntryDateDialogShift": "시간 이동",
"editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜",
"editEntryDateDialogTargetFieldsHeader": "수정할 필드",
"editEntryDateDialogHours": "시간",
"editEntryDateDialogMinutes": "분",

View file

@ -238,6 +238,8 @@
"renameEntryDialogLabel": "Novo nome",
"editEntryDialogTargetFieldsHeader": "Campos para modificar",
"editEntryDateDialogTitle": "Data e hora",
"editEntryDateDialogSetCustom": "Definir data personalizada",
"editEntryDateDialogCopyField": "Copiar de outra data",
@ -245,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "Extrair do título",
"editEntryDateDialogShift": "Mudança",
"editEntryDateDialogSourceFileModifiedDate": "Data de modificação do arquivo",
"editEntryDateDialogTargetFieldsHeader": "Campos para modificar",
"editEntryDateDialogHours": "Horas",
"editEntryDateDialogMinutes": "Minutos",

View file

@ -237,6 +237,8 @@
"renameEntryDialogLabel": "Новое название",
"editEntryDialogTargetFieldsHeader": "Поля для изменения",
"editEntryDateDialogTitle": "Дата и время",
"editEntryDateDialogSetCustom": "Установить дату",
"editEntryDateDialogCopyField": "Копировать с другой даты",
@ -244,7 +246,6 @@
"editEntryDateDialogExtractFromTitle": "Извлечь из названия",
"editEntryDateDialogShift": "Сдвиг",
"editEntryDateDialogSourceFileModifiedDate": "Дата изменения файла",
"editEntryDateDialogTargetFieldsHeader": "Поля для изменения",
"editEntryDateDialogHours": "Часов",
"editEntryDateDialogMinutes": "Минут",

View file

@ -230,6 +230,8 @@
"renameEntryDialogLabel": "Yeni ad",
"editEntryDialogTargetFieldsHeader": "Değiştirilecek alanlar",
"editEntryDateDialogTitle": "Tarih ve Saat",
"editEntryDateDialogSetCustom": "Özel tarih ayarla",
"editEntryDateDialogCopyField": "Başka bir tarihten kopyala",
@ -237,7 +239,6 @@
"editEntryDateDialogExtractFromTitle": "Başlıktan ayıkla",
"editEntryDateDialogShift": "Değişim",
"editEntryDateDialogSourceFileModifiedDate": "Dosya değiştirilme tarihi",
"editEntryDateDialogTargetFieldsHeader": "Değiştirilecek alanlar",
"editEntryDateDialogHours": "Saat",
"editEntryDateDialogMinutes": "Dakika",

View file

@ -238,6 +238,8 @@
"renameEntryDialogLabel": "新名称",
"editEntryDialogTargetFieldsHeader": "待修改的字段",
"editEntryDateDialogTitle": "日期和时间",
"editEntryDateDialogSetCustom": "设置自定义日期",
"editEntryDateDialogCopyField": "复制自其他日期",
@ -245,7 +247,6 @@
"editEntryDateDialogExtractFromTitle": "从标题提取",
"editEntryDateDialogShift": "转移",
"editEntryDateDialogSourceFileModifiedDate": "文件修改日期",
"editEntryDateDialogTargetFieldsHeader": "待修改的字段",
"editEntryDateDialogHours": "时",
"editEntryDateDialogMinutes": "分",

View file

@ -7,6 +7,7 @@ enum EntryInfoAction {
// general
editDate,
editLocation,
editDescription,
editRating,
editTags,
removeMetadata,
@ -23,6 +24,7 @@ class EntryInfoActions {
static const common = [
EntryInfoAction.editDate,
EntryInfoAction.editLocation,
EntryInfoAction.editDescription,
EntryInfoAction.editRating,
EntryInfoAction.editTags,
EntryInfoAction.removeMetadata,
@ -43,6 +45,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
return context.l10n.entryInfoActionEditDate;
case EntryInfoAction.editLocation:
return context.l10n.entryInfoActionEditLocation;
case EntryInfoAction.editDescription:
return context.l10n.entryInfoActionEditDescription;
case EntryInfoAction.editRating:
return context.l10n.entryInfoActionEditRating;
case EntryInfoAction.editTags:
@ -84,6 +88,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
return AIcons.date;
case EntryInfoAction.editLocation:
return AIcons.location;
case EntryInfoAction.editDescription:
return AIcons.description;
case EntryInfoAction.editRating:
return AIcons.editRating;
case EntryInfoAction.editTags:

View file

@ -31,6 +31,7 @@ enum EntrySetAction {
flip,
editDate,
editLocation,
editDescription,
editRating,
editTags,
removeMetadata,
@ -99,6 +100,7 @@ class EntrySetActions {
static const edit = [
EntrySetAction.editDate,
EntrySetAction.editLocation,
EntrySetAction.editDescription,
EntrySetAction.editRating,
EntrySetAction.editTags,
EntrySetAction.removeMetadata,
@ -162,6 +164,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.entryInfoActionEditDate;
case EntrySetAction.editLocation:
return context.l10n.entryInfoActionEditLocation;
case EntrySetAction.editDescription:
return context.l10n.entryInfoActionEditDescription;
case EntrySetAction.editRating:
return context.l10n.entryInfoActionEditRating;
case EntrySetAction.editTags:
@ -229,6 +233,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.date;
case EntrySetAction.editLocation:
return AIcons.location;
case EntrySetAction.editDescription:
return AIcons.description;
case EntrySetAction.editRating:
return AIcons.editRating;
case EntrySetAction.editTags:

View file

@ -257,6 +257,8 @@ class AvesEntry {
bool get canEditLocation => canEdit && canEditExif;
bool get canEditDescription => canEdit && (canEditExif || canEditXmp);
bool get canEditRating => canEdit && canEditXmp;
bool get canEditTags => canEdit && canEditXmp;

View file

@ -140,6 +140,54 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
return _changeOrientation(() => metadataEditService.flip(this));
}
// write:
// - Exif / ImageDescription
// - IPTC / caption-abstract, if IPTC exists
// - XMP / dc:description
Future<Set<EntryDataType>> editDescription(String? description) async {
final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> metadata = {};
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
if (canEditExif) {
metadata[MetadataType.exif] = {MetadataField.exifImageDescription.exifInterfaceTag!: description};
}
if (canEditIptc) {
final iptc = await metadataFetchService.getIptc(this);
if (iptc != null) {
editIptcValues(iptc, IPTC.applicationRecord, IPTC.captionAbstractTag, {if (description != null) description});
metadata[MetadataType.iptc] = iptc;
}
}
if (canEditXmp) {
metadata[MetadataType.xmp] = await _editXmp((descriptions) {
final modified = XMP.setAttribute(
descriptions,
XMP.dcDescription,
description,
namespace: Namespaces.dc,
strat: XmpEditStrategy.always,
);
if (modified && missingDate != null) {
editCreateDateXmp(descriptions, missingDate);
}
return modified;
});
}
final newFields = await metadataEditService.editMetadata(this, metadata);
if (newFields.isNotEmpty) {
dataTypes.addAll({
EntryDataType.basic,
});
}
return dataTypes;
}
// write:
// - IPTC / keywords, if IPTC exists
// - XMP / dc:subject
@ -152,7 +200,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
if (canEditIptc) {
final iptc = await metadataFetchService.getIptc(this);
if (iptc != null) {
editTagsIptc(iptc, tags);
editIptcValues(iptc, IPTC.applicationRecord, IPTC.keywordsTag, tags);
metadata[MetadataType.iptc] = iptc;
}
}
@ -245,9 +293,18 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
};
}
static void editIptcValues(List<Map<String, dynamic>> iptc, int record, int tag, Set<String> values) {
iptc.removeWhere((v) => v['record'] == record && v['tag'] == tag);
iptc.add({
'record': record,
'tag': tag,
'values': values.map((v) => utf8.encode(v)).toList(),
});
}
@visibleForTesting
static void editCreateDateXmp(List<XmlNode> descriptions, DateTime? date) {
XMP.setAttribute(
static bool editCreateDateXmp(List<XmlNode> descriptions, DateTime? date) {
return XMP.setAttribute(
descriptions,
XMP.xmpCreateDate,
date != null ? XMP.toXmpDate(date) : null,
@ -256,16 +313,6 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
);
}
@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 bool editTagsXmp(List<XmlNode> descriptions, Set<String> tags) {
return XMP.setStringBag(
@ -366,7 +413,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
}
Future<DateModifier?> _applyDateModifierToEntry(DateModifier modifier) async {
Set<MetadataField> mainMetadataDate() => {canEditExif ? MetadataField.exifDateOriginal : MetadataField.xmpCreateDate};
Set<MetadataField> mainMetadataDate() => {canEditExif ? MetadataField.exifDateOriginal : MetadataField.xmpXmpCreateDate};
switch (modifier.action) {
case DateEditAction.copyField:

View file

@ -6,12 +6,12 @@ import 'package:flutter/widgets.dart';
@immutable
class DateModifier extends Equatable {
static const writableDateFields = [
static const writableFields = [
MetadataField.exifDate,
MetadataField.exifDateOriginal,
MetadataField.exifDateDigitized,
MetadataField.exifGpsDatestamp,
MetadataField.xmpCreateDate,
MetadataField.xmpXmpCreateDate,
];
final DateEditAction action;

View file

@ -36,7 +36,8 @@ enum MetadataField {
exifGpsTrack,
exifGpsTrackRef,
exifGpsVersionId,
xmpCreateDate,
exifImageDescription,
xmpXmpCreateDate,
}
class MetadataFields {
@ -114,8 +115,9 @@ extension ExtraMetadataField on MetadataField {
case MetadataField.exifGpsTrack:
case MetadataField.exifGpsTrackRef:
case MetadataField.exifGpsVersionId:
case MetadataField.exifImageDescription:
return MetadataType.exif;
case MetadataField.xmpCreateDate:
case MetadataField.xmpXmpCreateDate:
return MetadataType.xmp;
}
}
@ -192,8 +194,27 @@ extension ExtraMetadataField on MetadataField {
return 'GPSTrackRef';
case MetadataField.exifGpsVersionId:
return 'GPSVersionID';
case MetadataField.xmpCreateDate:
case MetadataField.exifImageDescription:
return 'ImageDescription';
case MetadataField.xmpXmpCreateDate:
return null;
}
}
String get title {
switch (this) {
case MetadataField.exifDate:
return 'Exif date';
case MetadataField.exifDateOriginal:
return 'Exif original date';
case MetadataField.exifDateDigitized:
return 'Exif digitized date';
case MetadataField.exifGpsDatestamp:
return 'Exif GPS date';
case MetadataField.xmpXmpCreateDate:
return 'XMP xmp:CreateDate';
default:
return name;
}
}
}

View file

@ -3,4 +3,5 @@ class IPTC {
// ApplicationRecord tags
static const int keywordsTag = 25;
static const int captionAbstractTag = 120;
}

View file

@ -35,6 +35,8 @@ abstract class MetadataFetchService {
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
Future<DateTime?> getDate(AvesEntry entry, MetadataField field);
Future<String?> getDescription(AvesEntry entry);
}
class PlatformMetadataFetchService implements MetadataFetchService {
@ -75,7 +77,7 @@ class PlatformMetadataFetchService implements MetadataFetchService {
// 'latitude': latitude (double)
// 'longitude': longitude (double)
// 'xmpSubjects': ';' separated XMP subjects (string)
// 'xmpTitleDescription': XMP title or XMP description (string)
// 'xmpTitleDescription': XMP title (string)
final result = await _platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
@ -268,4 +270,20 @@ class PlatformMetadataFetchService implements MetadataFetchService {
}
return null;
}
@override
Future<String?> getDescription(AvesEntry entry) async {
try {
return await _platform.invokeMethod('getDescription', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return null;
}
}

View file

@ -16,6 +16,7 @@ class AIcons {
static const IconData checked = Icons.done_outlined;
static const IconData counter = Icons.plus_one_outlined;
static const IconData date = Icons.calendar_today_outlined;
static const IconData description = Icons.description_outlined;
static const IconData disc = Icons.fiber_manual_record;
static const IconData display = Icons.light_mode_outlined;
static const IconData error = Icons.error_outline;

View file

@ -35,6 +35,7 @@ class XMP {
static const rdfRoot = 'RDF';
static const rdfDescription = 'Description';
static const containerDirectory = 'Directory';
static const dcDescription = 'description';
static const dcSubject = 'subject';
static const msPhotoRating = 'Rating';
static const xmpRating = 'Rating';

View file

@ -496,6 +496,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case EntrySetAction.flip:
case EntrySetAction.editDate:
case EntrySetAction.editLocation:
case EntrySetAction.editDescription:
case EntrySetAction.editRating:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata:

View file

@ -92,6 +92,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.flip:
case EntrySetAction.editDate:
case EntrySetAction.editLocation:
case EntrySetAction.editDescription:
case EntrySetAction.editRating:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata:
@ -143,6 +144,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.flip:
case EntrySetAction.editDate:
case EntrySetAction.editLocation:
case EntrySetAction.editDescription:
case EntrySetAction.editRating:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata:
@ -219,6 +221,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.editLocation:
_editLocation(context);
break;
case EntrySetAction.editDescription:
_editDescription(context);
break;
case EntrySetAction.editRating:
_editRating(context);
break;
@ -490,6 +495,16 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
await _edit(context, entries, (entry) => entry.editLocation(location));
}
Future<void> _editDescription(BuildContext context) async {
final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditDescription);
if (entries == null || entries.isEmpty) return;
final description = await selectDescription(context, entries);
if (description == null) return;
await _edit(context, entries, (entry) => entry.editDescription(description));
}
Future<void> _editRating(BuildContext context) async {
final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditRating);
if (entries == null || entries.isEmpty) return;

View file

@ -3,9 +3,11 @@ import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/edit_date_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/edit_description_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/edit_location_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/edit_rating_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/edit_tags_dialog.dart';
@ -17,39 +19,49 @@ mixin EntryEditorMixin {
Future<DateModifier?> selectDateModifier(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
if (entries.isEmpty) return null;
final modifier = await showDialog<DateModifier>(
return showDialog<DateModifier>(
context: context,
builder: (context) => EditEntryDateDialog(
entry: entries.first,
collection: collection,
),
);
return modifier;
}
Future<LatLng?> selectLocation(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
if (entries.isEmpty) return null;
final location = await showDialog<LatLng>(
return showDialog<LatLng>(
context: context,
builder: (context) => EditEntryLocationDialog(
entry: entries.first,
collection: collection,
),
);
return location;
}
Future<String?> selectDescription(BuildContext context, Set<AvesEntry> entries) async {
if (entries.isEmpty) return null;
final initialDescription = await metadataFetchService.getDescription(entries.first) ?? '';
return showDialog<String>(
context: context,
builder: (context) => EditEntryDescriptionDialog(
initialDescription: initialDescription,
),
);
}
Future<int?> selectRating(BuildContext context, Set<AvesEntry> entries) async {
if (entries.isEmpty) return null;
final rating = await showDialog<int>(
return showDialog<int>(
context: context,
builder: (context) => EditEntryRatingDialog(
entry: entries.first,
),
);
return rating;
}
Future<Map<AvesEntry, Set<String>>?> selectTags(BuildContext context, Set<AvesEntry> entries) async {

View file

@ -38,7 +38,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
late ValueNotifier<int> _shiftHour, _shiftMinute;
late ValueNotifier<String> _shiftSign;
bool _showOptions = false;
final Set<MetadataField> _fields = {...DateModifier.writableDateFields};
final Set<MetadataField> _fields = {...DateModifier.writableFields};
DateTime get copyItemDate => _copyItemSource.bestDate ?? DateTime.now();
@ -276,14 +276,14 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
children: [
ExpansionPanel(
headerBuilder: (context, isExpanded) => ListTile(
title: Text(context.l10n.editEntryDateDialogTargetFieldsHeader),
title: Text(context.l10n.editEntryDialogTargetFieldsHeader),
),
body: Column(
children: DateModifier.writableDateFields
children: DateModifier.writableFields
.map((field) => SwitchListTile(
value: _fields.contains(field),
onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)),
title: Text(_fieldTitle(field)),
title: Text(field.title),
))
.toList(),
),
@ -330,23 +330,6 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
}
}
String _fieldTitle(MetadataField field) {
switch (field) {
case MetadataField.exifDate:
return 'Exif date';
case MetadataField.exifDateOriginal:
return 'Exif original date';
case MetadataField.exifDateDigitized:
return 'Exif digitized date';
case MetadataField.exifGpsDatestamp:
return 'Exif GPS date';
case MetadataField.xmpCreateDate:
return 'XMP xmp:CreateDate';
default:
return field.name;
}
}
Future<void> _editDate() async {
final _date = await showDatePicker(
context: context,

View file

@ -0,0 +1,58 @@
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 EditEntryDescriptionDialog extends StatefulWidget {
final String initialDescription;
const EditEntryDescriptionDialog({
super.key,
required this.initialDescription,
});
@override
State<EditEntryDescriptionDialog> createState() => _EditEntryDescriptionDialogState();
}
class _EditEntryDescriptionDialogState extends State<EditEntryDescriptionDialog> {
late final TextEditingController _textController;
@override
void initState() {
super.initState();
_textController = TextEditingController(text: widget.initialDescription);
}
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Builder(builder: (context) {
final l10n = context.l10n;
return AvesDialog(
title: l10n.editEntryDescriptionDialogTitle,
content: TextField(
controller: _textController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
maxLines: null,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => _submit(context),
child: Text(l10n.applyButtonLabel),
),
],
);
}),
);
}
void _submit(BuildContext context) => Navigator.pop(context, _textController.text);
}

View file

@ -79,7 +79,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
controller: _latitudeController,
focusNode: _latitudeFocusNode,
decoration: InputDecoration(
labelText: context.l10n.editEntryLocationDialogLatitude,
labelText: l10n.editEntryLocationDialogLatitude,
hintText: coordinateFormatter.format(Constants.pointNemo.latitude),
),
onChanged: (_) => _validate(),
@ -88,7 +88,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
controller: _longitudeController,
focusNode: _longitudeFocusNode,
decoration: InputDecoration(
labelText: context.l10n.editEntryLocationDialogLongitude,
labelText: l10n.editEntryLocationDialogLongitude,
hintText: coordinateFormatter.format(Constants.pointNemo.longitude),
),
onChanged: (_) => _validate(),
@ -131,7 +131,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
builder: (context, isValid, child) {
return TextButton(
onPressed: isValid ? () => _submit(context) : null,
child: Text(context.l10n.applyButtonLabel),
child: Text(l10n.applyButtonLabel),
);
},
),

View file

@ -35,6 +35,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
// general
case EntryInfoAction.editDate:
case EntryInfoAction.editLocation:
case EntryInfoAction.editDescription:
case EntryInfoAction.editRating:
case EntryInfoAction.editTags:
case EntryInfoAction.removeMetadata:
@ -59,6 +60,8 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
return entry.canEditDate;
case EntryInfoAction.editLocation:
return entry.canEditLocation;
case EntryInfoAction.editDescription:
return entry.canEditDescription;
case EntryInfoAction.editRating:
return entry.canEditRating;
case EntryInfoAction.editTags:
@ -89,6 +92,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
case EntryInfoAction.editLocation:
await _editLocation(context);
break;
case EntryInfoAction.editDescription:
await _editDescription(context);
break;
case EntryInfoAction.editRating:
await _editRating(context);
break;
@ -131,6 +137,13 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
await edit(context, () => entry.editLocation(location));
}
Future<void> _editDescription(BuildContext context) async {
final description = await selectDescription(context, {entry});
if (description == null) return;
await edit(context, () => entry.editDescription(description));
}
Future<void> _editRating(BuildContext context) async {
final rating = await selectRating(context, {entry});
if (rating == null) return;

View file

@ -1,43 +1,61 @@
{
"de": [
"entryInfoActionEditDescription",
"editEntryDescriptionDialogTitle",
"settingsConfirmationAfterMoveToBinItems",
"settingsViewerGestureSideTapNext"
],
"es": [
"entryInfoActionEditDescription",
"editEntryDescriptionDialogTitle",
"settingsConfirmationAfterMoveToBinItems"
],
"fr": [
"entryInfoActionEditDescription",
"editEntryDescriptionDialogTitle",
"settingsViewerGestureSideTapNext"
],
"id": [
"entryInfoActionEditDescription",
"editEntryDescriptionDialogTitle",
"settingsConfirmationAfterMoveToBinItems",
"settingsViewerGestureSideTapNext"
],
"it": [
"entryInfoActionEditDescription",
"editEntryDescriptionDialogTitle",
"settingsConfirmationAfterMoveToBinItems",
"settingsViewerGestureSideTapNext"
],
"ja": [
"entryInfoActionEditDescription",
"editEntryDescriptionDialogTitle",
"settingsConfirmationAfterMoveToBinItems",
"settingsViewerGestureSideTapNext"
],
"ko": [
"entryInfoActionEditDescription",
"editEntryDescriptionDialogTitle",
"settingsViewerGestureSideTapNext"
],
"pt": [
"entryInfoActionEditDescription",
"editEntryDescriptionDialogTitle",
"settingsConfirmationAfterMoveToBinItems",
"settingsViewerGestureSideTapNext"
],
"ru": [
"entryInfoActionEditDescription",
"filterOnThisDayLabel",
"editEntryDescriptionDialogTitle",
"settingsConfirmationAfterMoveToBinItems",
"settingsViewerGestureSideTapNext",
"settingsSlideshowFillScreen",
@ -49,6 +67,7 @@
"tr": [
"slideshowActionResume",
"slideshowActionShowInCollection",
"entryInfoActionEditDescription",
"filterOnThisDayLabel",
"slideshowVideoPlaybackSkip",
"slideshowVideoPlaybackMuted",
@ -60,6 +79,7 @@
"wallpaperTargetHome",
"wallpaperTargetLock",
"wallpaperTargetHomeLock",
"editEntryDescriptionDialogTitle",
"menuActionSlideshow",
"settingsConfirmationAfterMoveToBinItems",
"settingsViewerGestureSideTapNext",
@ -81,6 +101,8 @@
],
"zh": [
"entryInfoActionEditDescription",
"editEntryDescriptionDialogTitle",
"settingsConfirmationAfterMoveToBinItems",
"settingsViewerGestureSideTapNext"
]