From c015c71fa9de22ae2780ab839c7ae672eefb2600 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 17 Aug 2022 20:03:22 +0200 Subject: [PATCH] #301 info: edit description --- CHANGELOG.md | 1 + .../channel/calls/MetadataFetchHandler.kt | 63 ++++++++++++++-- .../deckers/thibault/aves/metadata/XMP.kt | 2 +- lib/l10n/app_de.arb | 3 +- lib/l10n/app_en.arb | 6 +- lib/l10n/app_es.arb | 3 +- lib/l10n/app_fr.arb | 3 +- lib/l10n/app_id.arb | 3 +- lib/l10n/app_it.arb | 3 +- lib/l10n/app_ja.arb | 3 +- lib/l10n/app_ko.arb | 3 +- lib/l10n/app_pt.arb | 3 +- lib/l10n/app_ru.arb | 3 +- lib/l10n/app_tr.arb | 3 +- lib/l10n/app_zh.arb | 3 +- lib/model/actions/entry_info_actions.dart | 6 ++ lib/model/actions/entry_set_actions.dart | 6 ++ lib/model/entry.dart | 2 + lib/model/entry_metadata_edition.dart | 75 +++++++++++++++---- lib/model/metadata/date_modifier.dart | 4 +- lib/model/metadata/fields.dart | 27 ++++++- lib/ref/iptc.dart | 1 + .../metadata/metadata_fetch_service.dart | 20 ++++- lib/theme/icons.dart | 1 + lib/utils/xmp_utils.dart | 1 + lib/widgets/collection/app_bar.dart | 1 + .../collection/entry_set_action_delegate.dart | 15 ++++ .../common/action_mixins/entry_editor.dart | 24 ++++-- .../entry_editors/edit_date_dialog.dart | 25 +------ .../edit_description_dialog.dart | 58 ++++++++++++++ .../entry_editors/edit_location_dialog.dart | 6 +- .../action/entry_info_action_delegate.dart | 13 ++++ untranslated.json | 22 ++++++ 33 files changed, 342 insertions(+), 70 deletions(-) create mode 100644 lib/widgets/dialogs/entry_editors/edit_description_dialog.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5066077..d5978ad6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 45234542f..2280ddd9c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -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("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("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() 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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 2819915e8..8bdaf33d1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -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" diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 0b6314a68..680f6edb6 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2872833b6..40fb1a76a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5070fbbdd..09f052491 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 16cf37d2d..90dc90836 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -238,6 +238,8 @@ "renameEntryDialogLabel": "Nouveau nom", + "editEntryDialogTargetFieldsHeader": "Champs à modifier", + "editEntryDateDialogTitle": "Date & Heure", "editEntryDateDialogSetCustom": "Régler une date personnalisée", "editEntryDateDialogCopyField": "Copier d’une 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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 65b5d8463..c39b2d872 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -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", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 6527e85bf..2dcb32b73 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -238,6 +238,8 @@ "renameEntryDialogLabel": "Nuovo nome", + "editEntryDialogTargetFieldsHeader": "Campi da modificare", + "editEntryDateDialogTitle": "Data e ora", "editEntryDateDialogSetCustom": "Imposta data personalizzata", "editEntryDateDialogCopyField": "Copia da un’altra 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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 70da86577..ea180e6f1 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -238,6 +238,8 @@ "renameEntryDialogLabel": "新しい名前", + "editEntryDialogTargetFieldsHeader": "更新するフィールド", + "editEntryDateDialogTitle": "日時", "editEntryDateDialogSetCustom": "日を設定する", "editEntryDateDialogCopyField": "他の日からコピーする", @@ -245,7 +247,6 @@ "editEntryDateDialogExtractFromTitle": "タイトルから抽出する", "editEntryDateDialogShift": "シフト", "editEntryDateDialogSourceFileModifiedDate": "ファイル更新日", - "editEntryDateDialogTargetFieldsHeader": "更新するフィールド", "editEntryDateDialogHours": "時", "editEntryDateDialogMinutes": "分", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index b688821a0..c0b949c05 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -238,6 +238,8 @@ "renameEntryDialogLabel": "이름", + "editEntryDialogTargetFieldsHeader": "수정할 필드", + "editEntryDateDialogTitle": "날짜 및 시간", "editEntryDateDialogSetCustom": "지정 날짜로 편집", "editEntryDateDialogCopyField": "다른 날짜에서 지정", @@ -245,7 +247,6 @@ "editEntryDateDialogExtractFromTitle": "제목에서 추출", "editEntryDateDialogShift": "시간 이동", "editEntryDateDialogSourceFileModifiedDate": "파일 수정한 날짜", - "editEntryDateDialogTargetFieldsHeader": "수정할 필드", "editEntryDateDialogHours": "시간", "editEntryDateDialogMinutes": "분", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index fc09762ba..9ce02959a 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -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", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3a3a14338..fd9efd211 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -237,6 +237,8 @@ "renameEntryDialogLabel": "Новое название", + "editEntryDialogTargetFieldsHeader": "Поля для изменения", + "editEntryDateDialogTitle": "Дата и время", "editEntryDateDialogSetCustom": "Установить дату", "editEntryDateDialogCopyField": "Копировать с другой даты", @@ -244,7 +246,6 @@ "editEntryDateDialogExtractFromTitle": "Извлечь из названия", "editEntryDateDialogShift": "Сдвиг", "editEntryDateDialogSourceFileModifiedDate": "Дата изменения файла", - "editEntryDateDialogTargetFieldsHeader": "Поля для изменения", "editEntryDateDialogHours": "Часов", "editEntryDateDialogMinutes": "Минут", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index fdd7fb364..61bd8cc50 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -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", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9a6f0ea2a..3f7370798 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -238,6 +238,8 @@ "renameEntryDialogLabel": "新名称", + "editEntryDialogTargetFieldsHeader": "待修改的字段", + "editEntryDateDialogTitle": "日期和时间", "editEntryDateDialogSetCustom": "设置自定义日期", "editEntryDateDialogCopyField": "复制自其他日期", @@ -245,7 +247,6 @@ "editEntryDateDialogExtractFromTitle": "从标题提取", "editEntryDateDialogShift": "转移", "editEntryDateDialogSourceFileModifiedDate": "文件修改日期", - "editEntryDateDialogTargetFieldsHeader": "待修改的字段", "editEntryDateDialogHours": "时", "editEntryDateDialogMinutes": "分", diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index bba0cf0de..eca523345 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -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: diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index 997f819ec..f6f25d39d 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -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: diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 38781677d..ca9be6a26 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -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; diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index a1f8bf829..f6fdc4cc1 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -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> editDescription(String? description) async { + final Set dataTypes = {}; + final Map 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> iptc, int record, int tag, Set 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 descriptions, DateTime? date) { - XMP.setAttribute( + static bool editCreateDateXmp(List 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> iptc, Set tags) { - iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag); - iptc.add({ - 'record': IPTC.applicationRecord, - 'tag': IPTC.keywordsTag, - 'values': tags.map((v) => utf8.encode(v)).toList(), - }); - } - @visibleForTesting static bool editTagsXmp(List descriptions, Set tags) { return XMP.setStringBag( @@ -366,7 +413,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { } Future _applyDateModifierToEntry(DateModifier modifier) async { - Set mainMetadataDate() => {canEditExif ? MetadataField.exifDateOriginal : MetadataField.xmpCreateDate}; + Set mainMetadataDate() => {canEditExif ? MetadataField.exifDateOriginal : MetadataField.xmpXmpCreateDate}; switch (modifier.action) { case DateEditAction.copyField: diff --git a/lib/model/metadata/date_modifier.dart b/lib/model/metadata/date_modifier.dart index 3e0c20f94..53be123cd 100644 --- a/lib/model/metadata/date_modifier.dart +++ b/lib/model/metadata/date_modifier.dart @@ -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; diff --git a/lib/model/metadata/fields.dart b/lib/model/metadata/fields.dart index 040e5eaaa..1d5b54c4c 100644 --- a/lib/model/metadata/fields.dart +++ b/lib/model/metadata/fields.dart @@ -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; + } + } } diff --git a/lib/ref/iptc.dart b/lib/ref/iptc.dart index 8e88eea70..74efd75e7 100644 --- a/lib/ref/iptc.dart +++ b/lib/ref/iptc.dart @@ -3,4 +3,5 @@ class IPTC { // ApplicationRecord tags static const int keywordsTag = 25; + static const int captionAbstractTag = 120; } diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 59e5a8641..b4f741675 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -35,6 +35,8 @@ abstract class MetadataFetchService { Future getContentResolverProp(AvesEntry entry, String prop); Future getDate(AvesEntry entry, MetadataField field); + + Future 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', { 'mimeType': entry.mimeType, 'uri': entry.uri, @@ -268,4 +270,20 @@ class PlatformMetadataFetchService implements MetadataFetchService { } return null; } + + @override + Future getDescription(AvesEntry entry) async { + try { + return await _platform.invokeMethod('getDescription', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + }); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return null; + } } diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index f4179c89f..5fb0e56de 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -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; diff --git a/lib/utils/xmp_utils.dart b/lib/utils/xmp_utils.dart index 883afb33a..8d4f98c0a 100644 --- a/lib/utils/xmp_utils.dart +++ b/lib/utils/xmp_utils.dart @@ -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'; diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 53cb7715c..2bb0ffe44 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -496,6 +496,7 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.flip: case EntrySetAction.editDate: case EntrySetAction.editLocation: + case EntrySetAction.editDescription: case EntrySetAction.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index ebb532e35..f8a9bb9cd 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -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 _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 _editRating(BuildContext context) async { final entries = await _getEditableTargetItems(context, canEdit: (entry) => entry.canEditRating); if (entries == null || entries.isEmpty) return; diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 9b0de9185..a70aac16f 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -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 selectDateModifier(BuildContext context, Set entries, CollectionLens? collection) async { if (entries.isEmpty) return null; - final modifier = await showDialog( + return showDialog( context: context, builder: (context) => EditEntryDateDialog( entry: entries.first, collection: collection, ), ); - return modifier; } Future selectLocation(BuildContext context, Set entries, CollectionLens? collection) async { if (entries.isEmpty) return null; - final location = await showDialog( + return showDialog( context: context, builder: (context) => EditEntryLocationDialog( entry: entries.first, collection: collection, ), ); - return location; + } + + Future selectDescription(BuildContext context, Set entries) async { + if (entries.isEmpty) return null; + + final initialDescription = await metadataFetchService.getDescription(entries.first) ?? ''; + + return showDialog( + context: context, + builder: (context) => EditEntryDescriptionDialog( + initialDescription: initialDescription, + ), + ); } Future selectRating(BuildContext context, Set entries) async { if (entries.isEmpty) return null; - final rating = await showDialog( + return showDialog( context: context, builder: (context) => EditEntryRatingDialog( entry: entries.first, ), ); - return rating; } Future>?> selectTags(BuildContext context, Set entries) async { diff --git a/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart index 749d0054c..619b89bab 100644 --- a/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart @@ -38,7 +38,7 @@ class _EditEntryDateDialogState extends State { late ValueNotifier _shiftHour, _shiftMinute; late ValueNotifier _shiftSign; bool _showOptions = false; - final Set _fields = {...DateModifier.writableDateFields}; + final Set _fields = {...DateModifier.writableFields}; DateTime get copyItemDate => _copyItemSource.bestDate ?? DateTime.now(); @@ -276,14 +276,14 @@ class _EditEntryDateDialogState extends State { 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 { } } - 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 _editDate() async { final _date = await showDatePicker( context: context, diff --git a/lib/widgets/dialogs/entry_editors/edit_description_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_description_dialog.dart new file mode 100644 index 000000000..ac43a2b2c --- /dev/null +++ b/lib/widgets/dialogs/entry_editors/edit_description_dialog.dart @@ -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 createState() => _EditEntryDescriptionDialogState(); +} + +class _EditEntryDescriptionDialogState extends State { + 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); +} diff --git a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart index 649052d67..943b2fb08 100644 --- a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart @@ -79,7 +79,7 @@ class _EditEntryLocationDialogState extends State { 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 { 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 { builder: (context, isValid, child) { return TextButton( onPressed: isValid ? () => _submit(context) : null, - child: Text(context.l10n.applyButtonLabel), + child: Text(l10n.applyButtonLabel), ); }, ), diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index 17a1e7a07..c273cd5c1 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -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 _editDescription(BuildContext context) async { + final description = await selectDescription(context, {entry}); + if (description == null) return; + + await edit(context, () => entry.editDescription(description)); + } + Future _editRating(BuildContext context) async { final rating = await selectRating(context, {entry}); if (rating == null) return; diff --git a/untranslated.json b/untranslated.json index b2de325e4..58c67149d 100644 --- a/untranslated.json +++ b/untranslated.json @@ -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" ]