diff --git a/lib/model/entry.dart b/lib/model/entry.dart index d7650828d..1f419782e 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -7,8 +7,6 @@ import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; -import 'package:aves/model/metadata/date_modifier.dart'; -import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/video/metadata.dart'; import 'package:aves/ref/mime_types.dart'; @@ -18,7 +16,6 @@ import 'package:aves/services/geocoding_service.dart'; import 'package:aves/services/metadata/svg_metadata_service.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/utils/change_notifier.dart'; -import 'package:aves/utils/time_utils.dart'; import 'package:collection/collection.dart'; import 'package:country_code/country_code.dart'; import 'package:flutter/foundation.dart'; @@ -481,14 +478,14 @@ class AvesEntry { 'width': size.width.ceil(), 'height': size.height.ceil(), }; - await _applyNewFields(fields, persist: persist); + await applyNewFields(fields, persist: persist); } catalogMetadata = CatalogMetadata(contentId: contentId); } else { if (isVideo && (!isSized || durationMillis == 0)) { // exotic video that is not sized during loading final fields = await VideoMetadataFormatter.getLoadingMetadata(this); - await _applyNewFields(fields, persist: persist); + await applyNewFields(fields, persist: persist); } catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background); @@ -585,7 +582,7 @@ class AvesEntry { }.whereNotNull().where((v) => v.isNotEmpty).join(', '); } - Future _applyNewFields(Map newFields, {required bool persist}) async { + Future applyNewFields(Map newFields, {required bool persist}) async { final oldDateModifiedSecs = this.dateModifiedSecs; final oldRotationDegrees = this.rotationDegrees; final oldIsFlipped = this.isFlipped; @@ -646,116 +643,12 @@ class AvesEntry { final updatedEntry = await mediaFileService.getEntry(uri, mimeType); if (updatedEntry != null) { - await _applyNewFields(updatedEntry.toMap(), persist: persist); + await applyNewFields(updatedEntry.toMap(), persist: persist); } await catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist); await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale); } - Future> _changeOrientation(Future> Function() apply) async { - final dataTypes = await setMetadataDateIfMissing(); - - final newFields = await apply(); - // applying fields is only useful for a smoother visual change, - // as proper refreshing and persistence happens at the caller level - await _applyNewFields(newFields, persist: false); - if (newFields.isNotEmpty) { - dataTypes.addAll({ - EntryDataType.basic, - EntryDataType.catalog, - }); - } - return dataTypes; - } - - Future> rotate({required bool clockwise}) { - return _changeOrientation(() => metadataEditService.rotate(this, clockwise: clockwise)); - } - - Future> flip() { - return _changeOrientation(() => metadataEditService.flip(this)); - } - - 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 (_) {} - break; - default: - date = await metadataFetchService.getDate(this, source.toMetadataField()!); - 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.editDate(this, modifier); - return newFields.isEmpty - ? {} - : { - EntryDataType.basic, - EntryDataType.catalog, - }; - } - - // when editing a file that has no metadata date, - // we will set one, using the file modified date, if any - Future> setMetadataDateIfMissing() async { - if (path == null) return {}; - - // 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 {}; - - if (canEditExif) { - return await editDate(DateModifier.copyField( - const {MetadataField.exifDateOriginal}, - DateFieldSource.fileModifiedDate, - )); - } - // TODO TLAD [metadata] set XMP / xmp:CreateDate - return {}; - } - - Future> removeMetadata(Set types) async { - final newFields = await metadataEditService.removeTypes(this, types); - return newFields.isEmpty - ? {} - : { - EntryDataType.basic, - EntryDataType.catalog, - EntryDataType.address, - }; - } - Future delete() { final completer = Completer(); mediaFileService.delete(entries: {this}).listen( diff --git a/lib/model/entry_metadata_edition.dart b/lib/model/entry_metadata_edition.dart index e9428a7b8..a729ec41c 100644 --- a/lib/model/entry_metadata_edition.dart +++ b/lib/model/entry_metadata_edition.dart @@ -1,23 +1,118 @@ import 'dart:convert'; +import 'dart:io'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/ref/iptc.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/metadata/xmp.dart'; +import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/xmp_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:xml/xml.dart'; extension ExtraAvesEntryMetadataEdition on AvesEntry { + 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 (_) {} + break; + default: + date = await metadataFetchService.getDate(this, source.toMetadataField()!); + 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.editDate(this, modifier); + return newFields.isEmpty + ? {} + : { + EntryDataType.basic, + EntryDataType.catalog, + }; + } + + 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; + } + + final newFields = await apply(); + // applying fields is only useful for a smoother visual change, + // as proper refreshing and persistence happens at the caller level + await applyNewFields(newFields, persist: false); + if (newFields.isNotEmpty) { + dataTypes.addAll({ + EntryDataType.basic, + EntryDataType.catalog, + }); + } + return dataTypes; + } + + Future> rotate({required bool clockwise}) { + return _changeOrientation(() => metadataEditService.rotate(this, clockwise: clockwise)); + } + + Future> flip() { + return _changeOrientation(() => metadataEditService.flip(this)); + } + // write: // - IPTC / keywords, if IPTC exists // - XMP / dc:subject Future> editTags(Set tags) async { + final Set dataTypes = {}; final Map metadata = {}; - final dataTypes = await setMetadataDateIfMissing(); + // 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; + } if (canEditIptc) { final iptc = await metadataFetchService.getIptc(this); @@ -28,7 +123,13 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { } if (canEditXmp) { - metadata[MetadataType.xmp] = await _editXmp((descriptions) => editTagsXmp(descriptions, tags)); + metadata[MetadataType.xmp] = await _editXmp((descriptions) { + if (missingDate != null) { + editDateXmp(descriptions, missingDate!); + missingDate = null; + } + editTagsXmp(descriptions, tags); + }); } final newFields = await metadataEditService.editMetadata(this, metadata); @@ -46,12 +147,28 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { // - Exif / Rating // - Exif / RatingPercent Future> editRating(int? rating) async { + final Set dataTypes = {}; final Map metadata = {}; - final dataTypes = await setMetadataDateIfMissing(); + // 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; + } if (canEditXmp) { - metadata[MetadataType.xmp] = await _editXmp((descriptions) => editRatingXmp(descriptions, rating)); + metadata[MetadataType.xmp] = await _editXmp((descriptions) { + if (missingDate != null) { + editDateXmp(descriptions, missingDate!); + missingDate = null; + } + editRatingXmp(descriptions, rating); + }); } final newFields = await metadataEditService.editMetadata(this, metadata); @@ -61,6 +178,28 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { return dataTypes; } + Future> removeMetadata(Set types) async { + final newFields = await metadataEditService.removeTypes(this, types); + return newFields.isEmpty + ? {} + : { + EntryDataType.basic, + EntryDataType.catalog, + EntryDataType.address, + }; + } + + @visibleForTesting + static void editDateXmp(List descriptions, DateTime date) { + XMP.setAttribute( + descriptions, + XMP.xmpCreateDate, + XMP.toXmpDate(date), + namespace: Namespaces.xmp, + strat: XmpEditStrategy.always, + ); + } + @visibleForTesting static void editTagsIptc(List> iptc, Set tags) { iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag); @@ -102,6 +241,21 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry { // convenience + Future _getMissingMetadataDate() 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; + + try { + return await File(path!).lastModified(); + } on FileSystemException catch (_) {} + } + Future> _editXmp(void Function(List descriptions) apply) async { final xmp = await metadataFetchService.getXmp(this); final xmpString = xmp?.xmpString; diff --git a/lib/utils/xmp_utils.dart b/lib/utils/xmp_utils.dart index 798bb8771..c7fbe3173 100644 --- a/lib/utils/xmp_utils.dart +++ b/lib/utils/xmp_utils.dart @@ -37,6 +37,7 @@ class XMP { // attributes static const xXmptk = 'xmptk'; static const rdfAbout = 'about'; + static const xmpCreateDate = 'CreateDate'; static const xmpMetadataDate = 'MetadataDate'; static const xmpModifyDate = 'ModifyDate'; static const xmpNoteHasExtendedXMP = 'HasExtendedXMP'; diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index cec470c8e..3c6cff31f 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -6,6 +6,7 @@ import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart';