diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b90b87835..b34ff0cf5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -277,7 +277,7 @@ "editEntryDateDialogTitle": "Date & Time", "editEntryDateDialogSetCustom": "Set custom date", - "editEntryDateDialogCopyField": "Set from other date", + "editEntryDateDialogCopyField": "Copy from other date", "editEntryDateDialogExtractFromTitle": "Extract from title", "editEntryDateDialogShift": "Shift", "editEntryDateDialogSourceFileModifiedDate": "File modified date", diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 1f419782e..2e071117e 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -234,7 +234,7 @@ class AvesEntry { bool get canEdit => path != null; - bool get canEditDate => canEdit && canEditExif; + bool get canEditDate => canEdit && (canEditExif || canEditXmp); bool get canEditRating => canEdit && canEditXmp; diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index a729ec41c..a69300c39 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -14,66 +14,69 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:xml/xml.dart'; extension ExtraAvesEntryMetadataEdition on AvesEntry { - Future> editDate(DateModifier modifier) async { - switch (modifier.action) { - case DateEditAction.copyField: - DateTime? date; - final source = modifier.copyFieldSource; - if (source != null) { - switch (source) { - case DateFieldSource.fileModifiedDate: - try { - date = path != null ? await File(path!).lastModified() : null; - } on FileSystemException catch (_) {} + Future> editDate(DateModifier userModifier) async { + final Set dataTypes = {}; + + final appliedModifier = await _applyDateModifierToEntry(userModifier); + if (appliedModifier == null) { + await reportService.recordError('failed to get date for modifier=$userModifier, uri=$uri', null); + return {}; + } + + if (canEditExif && appliedModifier.fields.any((v) => v.type == MetadataType.exif)) { + final newFields = await metadataEditService.editExifDate(this, appliedModifier); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.catalog, + }); + } + } + + if (canEditXmp && appliedModifier.fields.any((v) => v.type == MetadataType.xmp)) { + final metadata = { + MetadataType.xmp: await _editXmp((descriptions) { + switch (appliedModifier.action) { + case DateEditAction.setCustom: + case DateEditAction.copyField: + case DateEditAction.extractFromTitle: + editCreateDateXmp(descriptions, appliedModifier.setDateTime); break; - default: - date = await metadataFetchService.getDate(this, source.toMetadataField()!); + case DateEditAction.shift: + final xmpDate = XMP.getString(descriptions, XMP.xmpCreateDate, namespace: Namespaces.xmp); + if (xmpDate != null) { + final date = DateTime.tryParse(xmpDate); + if (date != null) { + // TODO TLAD [date] DateTime.tryParse converts to UTC time, losing the time zone offset + final shiftedDate = date.add(Duration(minutes: appliedModifier.shiftMinutes!)); + editCreateDateXmp(descriptions, shiftedDate); + } else { + reportService.recordError('failed to parse XMP date=$xmpDate', null); + } + } + break; + case DateEditAction.remove: + editCreateDateXmp(descriptions, null); break; } - } - if (date != null) { - modifier = DateModifier.setCustom(modifier.fields, date); - } else { - await reportService.recordError('failed to get date for modifier=$modifier, uri=$uri', null); - return {}; - } - break; - case DateEditAction.extractFromTitle: - final date = parseUnknownDateFormat(bestTitle); - if (date != null) { - modifier = DateModifier.setCustom(modifier.fields, date); - } else { - await reportService.recordError('failed to get date for modifier=$modifier, uri=$uri', null); - return {}; - } - break; - case DateEditAction.setCustom: - case DateEditAction.shift: - case DateEditAction.remove: - break; + }), + }; + final newFields = await metadataEditService.editMetadata(this, metadata); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.catalog, + }); + } } - final newFields = await metadataEditService.editDate(this, modifier); - return newFields.isEmpty - ? {} - : { - EntryDataType.basic, - EntryDataType.catalog, - }; + + return dataTypes; } Future> _changeOrientation(Future> Function() apply) async { final Set dataTypes = {}; - // when editing a file that has no metadata date, - // we will set one, using the file modified date, if any - var missingDate = await _getMissingMetadataDate(); - if (missingDate != null && canEditExif) { - dataTypes.addAll(await editDate(DateModifier.setCustom( - const {MetadataField.exifDateOriginal}, - missingDate, - ))); - missingDate = null; - } + await _missingDateCheckAndExifEdit(dataTypes); final newFields = await apply(); // applying fields is only useful for a smoother visual change, @@ -103,16 +106,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { final Set dataTypes = {}; final Map metadata = {}; - // when editing a file that has no metadata date, - // we will set one, using the file modified date, if any - var missingDate = await _getMissingMetadataDate(); - if (missingDate != null && canEditExif) { - dataTypes.addAll(await editDate(DateModifier.setCustom( - const {MetadataField.exifDateOriginal}, - missingDate, - ))); - missingDate = null; - } + final missingDate = await _missingDateCheckAndExifEdit(dataTypes); if (canEditIptc) { final iptc = await metadataFetchService.getIptc(this); @@ -125,8 +119,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { if (canEditXmp) { metadata[MetadataType.xmp] = await _editXmp((descriptions) { if (missingDate != null) { - editDateXmp(descriptions, missingDate!); - missingDate = null; + editCreateDateXmp(descriptions, missingDate); } editTagsXmp(descriptions, tags); }); @@ -150,22 +143,12 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { final Set dataTypes = {}; final Map metadata = {}; - // when editing a file that has no metadata date, - // we will set one, using the file modified date, if any - var missingDate = await _getMissingMetadataDate(); - if (missingDate != null && canEditExif) { - dataTypes.addAll(await editDate(DateModifier.setCustom( - const {MetadataField.exifDateOriginal}, - missingDate, - ))); - missingDate = null; - } + final missingDate = await _missingDateCheckAndExifEdit(dataTypes); if (canEditXmp) { metadata[MetadataType.xmp] = await _editXmp((descriptions) { if (missingDate != null) { - editDateXmp(descriptions, missingDate!); - missingDate = null; + editCreateDateXmp(descriptions, missingDate); } editRatingXmp(descriptions, rating); }); @@ -190,11 +173,11 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { } @visibleForTesting - static void editDateXmp(List descriptions, DateTime date) { + static void editCreateDateXmp(List descriptions, DateTime? date) { XMP.setAttribute( descriptions, XMP.xmpCreateDate, - XMP.toXmpDate(date), + date != null ? XMP.toXmpDate(date) : null, namespace: Namespaces.xmp, strat: XmpEditStrategy.always, ); @@ -241,19 +224,69 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { // convenience - Future _getMissingMetadataDate() async { + // This method checks whether the item already has a metadata date, + // and adds a date (the file modified date) via Exif if possible. + // It returns a date if the caller needs to add it via other metadata types (e.g. XMP). + Future _missingDateCheckAndExifEdit(Set dataTypes) async { if (path == null) return null; // make sure entry is catalogued before we check whether is has a metadata date if (!isCatalogued) { await catalog(background: false, force: false, persist: true); } - final metadataDate = catalogMetadata?.dateMillis; - if (metadataDate != null && metadataDate > 0) return null; + final dateMillis = catalogMetadata?.dateMillis; + if (dateMillis != null && dateMillis > 0) return null; + late DateTime date; try { - return await File(path!).lastModified(); - } on FileSystemException catch (_) {} + date = await File(path!).lastModified(); + } on FileSystemException catch (_) { + return null; + } + + if (canEditExif) { + final newFields = await metadataEditService.editExifDate(this, DateModifier.setCustom(const {MetadataField.exifDateOriginal}, date)); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.catalog, + }); + return null; + } + } + + return date; + } + + Future _applyDateModifierToEntry(DateModifier modifier) async { + Set mainMetadataDate() => {canEditExif ? MetadataField.exifDateOriginal : MetadataField.xmpCreateDate}; + + switch (modifier.action) { + case DateEditAction.copyField: + DateTime? date; + final source = modifier.copyFieldSource; + if (source != null) { + switch (source) { + case DateFieldSource.fileModifiedDate: + try { + date = path != null ? await File(path!).lastModified() : null; + } on FileSystemException catch (_) {} + break; + default: + date = await metadataFetchService.getDate(this, source.toMetadataField()!); + break; + } + } + return date != null ? DateModifier.setCustom(mainMetadataDate(), date) : null; + case DateEditAction.extractFromTitle: + final date = parseUnknownDateFormat(bestTitle); + return date != null ? DateModifier.setCustom(mainMetadataDate(), date) : null; + case DateEditAction.setCustom: + return DateModifier.setCustom(mainMetadataDate(), modifier.setDateTime!); + case DateEditAction.shift: + case DateEditAction.remove: + return modifier; + } } Future> _editXmp(void Function(List descriptions) apply) async { diff --git a/lib/model/metadata/date_modifier.dart b/lib/model/metadata/date_modifier.dart index 96ef5ffc0..86c312f62 100644 --- a/lib/model/metadata/date_modifier.dart +++ b/lib/model/metadata/date_modifier.dart @@ -9,6 +9,7 @@ class DateModifier { MetadataField.exifDateOriginal, MetadataField.exifDateDigitized, MetadataField.exifGpsDate, + MetadataField.xmpCreateDate, ]; final DateEditAction action; diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index 768a87d16..530dc932b 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -3,6 +3,7 @@ enum MetadataField { exifDateOriginal, exifDateDigitized, exifGpsDate, + xmpCreateDate, } enum DateEditAction { @@ -91,7 +92,19 @@ extension ExtraMetadataType on MetadataType { } extension ExtraMetadataField on MetadataField { - String toExifInterfaceTag() { + MetadataType get type { + switch (this) { + case MetadataField.exifDate: + case MetadataField.exifDateOriginal: + case MetadataField.exifDateDigitized: + case MetadataField.exifGpsDate: + return MetadataType.exif; + case MetadataField.xmpCreateDate: + return MetadataType.xmp; + } + } + + String? toExifInterfaceTag() { switch (this) { case MetadataField.exifDate: return 'DateTime'; @@ -101,6 +114,8 @@ extension ExtraMetadataField on MetadataField { return 'DateTimeDigitized'; case MetadataField.exifGpsDate: return 'GPSDateStamp'; + case MetadataField.xmpCreateDate: + return null; } } } diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index b9489a691..164b5d7e9 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -4,6 +4,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/services/common/services.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; abstract class MetadataEditService { @@ -11,7 +12,7 @@ abstract class MetadataEditService { Future> flip(AvesEntry entry); - Future> editDate(AvesEntry entry, DateModifier modifier); + Future> editExifDate(AvesEntry entry, DateModifier modifier); Future> editMetadata(AvesEntry entry, Map modifier); @@ -70,13 +71,13 @@ class PlatformMetadataEditService implements MetadataEditService { } @override - Future> editDate(AvesEntry entry, DateModifier modifier) async { + Future> editExifDate(AvesEntry entry, DateModifier modifier) async { try { final result = await platform.invokeMethod('editDate', { 'entry': _toPlatformEntryMap(entry), 'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch, 'shiftMinutes': modifier.shiftMinutes, - 'fields': modifier.fields.map((v) => v.toExifInterfaceTag()).toList(), + 'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.toExifInterfaceTag()).whereNotNull().toList(), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { diff --git a/lib/utils/xmp_utils.dart b/lib/utils/xmp_utils.dart index c7fbe3173..d897e476a 100644 --- a/lib/utils/xmp_utils.dart +++ b/lib/utils/xmp_utils.dart @@ -79,6 +79,21 @@ class XMP { static String toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}'; + static String? getString( + List nodes, + String name, { + required String namespace, + }) { + for (final node in nodes) { + final attribute = node.getAttribute(name, namespace: namespace); + if (attribute != null) return attribute; + + final element = node.getElement(name, namespace: namespace); + if (element != null) return element.innerText; + } + return null; + } + static void _addNamespaces(XmlNode node, Map namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); // remove elements and attributes diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart index 5b3b280ab..306b2f572 100644 --- a/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart @@ -30,11 +30,7 @@ class _EditEntryDateDialogState extends State { late ValueNotifier _shiftHour, _shiftMinute; late ValueNotifier _shiftSign; bool _showOptions = false; - final Set _fields = { - MetadataField.exifDate, - MetadataField.exifDateDigitized, - MetadataField.exifDateOriginal, - }; + final Set _fields = {...DateModifier.writableDateFields}; // use a different shade to avoid having the same background // on the dialog (using the theme `dialogBackgroundColor`) @@ -99,10 +95,10 @@ class _EditEntryDateDialogState extends State { if (_action == DateEditAction.setCustom) _buildSetCustomContent(context), if (_action == DateEditAction.copyField) _buildCopyFieldContent(context), if (_action == DateEditAction.shift) _buildShiftContent(context), + (_action == DateEditAction.shift || _action == DateEditAction.remove)? _buildDestinationFields(context): const SizedBox(height: 8), ], ), ), - _buildDestinationFields(context), ], actions: [ TextButton( @@ -135,7 +131,7 @@ class _EditEntryDateDialogState extends State { final use24hour = context.select((v) => v.alwaysUse24HourFormat); return Padding( - padding: const EdgeInsets.only(left: 16, top: 4, right: 12), + padding: const EdgeInsets.only(left: 16, right: 8), child: Row( children: [ Expanded(child: Text(formatDateTime(_setDateTime, locale, use24hour))), @@ -151,7 +147,7 @@ class _EditEntryDateDialogState extends State { Widget _buildCopyFieldContent(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.only(left: 16, top: 0, right: 16), child: DropdownButton( items: DateFieldSource.values .map((v) => DropdownMenuItem( @@ -308,6 +304,8 @@ class _EditEntryDateDialogState extends State { return 'Exif digitized date'; case MetadataField.exifGpsDate: return 'Exif GPS date'; + case MetadataField.xmpCreateDate: + return 'XMP xmp:CreateDate'; } } @@ -337,13 +335,16 @@ class _EditEntryDateDialogState extends State { } DateModifier _getModifier() { + // fields to modify are only set for the `shift` and `remove` actions, + // as the effective fields for the other actions will depend on + // whether each item supports Exif edition switch (_action) { case DateEditAction.setCustom: - return DateModifier.setCustom(_fields, _setDateTime); + return DateModifier.setCustom(const {}, _setDateTime); case DateEditAction.copyField: - return DateModifier.copyField(_fields, _copyFieldSource); + return DateModifier.copyField(const {}, _copyFieldSource); case DateEditAction.extractFromTitle: - return DateModifier.extractFromTitle(_fields); + return DateModifier.extractFromTitle(const {}); case DateEditAction.shift: final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1); return DateModifier.shift(_fields, shiftTotalMinutes); diff --git a/test/utils/xmp_utils_test.dart b/test/utils/xmp_utils_test.dart index 24dcc403f..a81497a6b 100644 --- a/test/utils/xmp_utils_test.dart +++ b/test/utils/xmp_utils_test.dart @@ -13,6 +13,15 @@ void main() { ) : null; + List _getDescriptions(String xmpString) { + final xmpDoc = XmlDocument.parse(xmpString); + final root = xmpDoc.rootElement; + final rdf = root.getElement(XMP.rdfRoot, namespace: Namespaces.rdf); + return rdf!.children.where((node) { + return node is XmlElement && node.name.local == XMP.rdfDescription && node.name.namespaceUri == Namespaces.rdf; + }).toList(); + } + const inMultiDescriptionRatings = ''' @@ -79,6 +88,12 @@ void main() { '''; + test('Get string', () async { + expect(XMP.getString(_getDescriptions(inRatingAttribute), XMP.xmpRating, namespace: Namespaces.xmp), '5'); + expect(XMP.getString(_getDescriptions(inRatingElement), XMP.xmpRating, namespace: Namespaces.xmp), '5'); + expect(XMP.getString(_getDescriptions(inSubjects), XMP.xmpRating, namespace: Namespaces.xmp), null); + }); + test('Set tags without existing XMP', () async { final modifyDate = DateTime.now(); final xmpDate = XMP.toXmpDate(modifyDate);