info: edit date for GIF

This commit is contained in:
Thibault Deckers 2022-01-04 18:33:02 +09:00
parent a3e18d3b3a
commit b94097bda7
9 changed files with 180 additions and 99 deletions

View file

@ -277,7 +277,7 @@
"editEntryDateDialogTitle": "Date & Time", "editEntryDateDialogTitle": "Date & Time",
"editEntryDateDialogSetCustom": "Set custom date", "editEntryDateDialogSetCustom": "Set custom date",
"editEntryDateDialogCopyField": "Set from other date", "editEntryDateDialogCopyField": "Copy from other date",
"editEntryDateDialogExtractFromTitle": "Extract from title", "editEntryDateDialogExtractFromTitle": "Extract from title",
"editEntryDateDialogShift": "Shift", "editEntryDateDialogShift": "Shift",
"editEntryDateDialogSourceFileModifiedDate": "File modified date", "editEntryDateDialogSourceFileModifiedDate": "File modified date",

View file

@ -234,7 +234,7 @@ class AvesEntry {
bool get canEdit => path != null; bool get canEdit => path != null;
bool get canEditDate => canEdit && canEditExif; bool get canEditDate => canEdit && (canEditExif || canEditXmp);
bool get canEditRating => canEdit && canEditXmp; bool get canEditRating => canEdit && canEditXmp;

View file

@ -14,66 +14,69 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
extension ExtraAvesEntryMetadataEdition on AvesEntry { extension ExtraAvesEntryMetadataEdition on AvesEntry {
Future<Set<EntryDataType>> editDate(DateModifier modifier) async { Future<Set<EntryDataType>> editDate(DateModifier userModifier) async {
switch (modifier.action) { final Set<EntryDataType> dataTypes = {};
case DateEditAction.copyField:
DateTime? date; final appliedModifier = await _applyDateModifierToEntry(userModifier);
final source = modifier.copyFieldSource; if (appliedModifier == null) {
if (source != null) { await reportService.recordError('failed to get date for modifier=$userModifier, uri=$uri', null);
switch (source) { return {};
case DateFieldSource.fileModifiedDate: }
try {
date = path != null ? await File(path!).lastModified() : null; if (canEditExif && appliedModifier.fields.any((v) => v.type == MetadataType.exif)) {
} on FileSystemException catch (_) {} 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; break;
default: case DateEditAction.shift:
date = await metadataFetchService.getDate(this, source.toMetadataField()!); 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; break;
} }
} }),
if (date != null) { };
modifier = DateModifier.setCustom(modifier.fields, date); final newFields = await metadataEditService.editMetadata(this, metadata);
} else { if (newFields.isNotEmpty) {
await reportService.recordError('failed to get date for modifier=$modifier, uri=$uri', null); dataTypes.addAll({
return {}; EntryDataType.basic,
} EntryDataType.catalog,
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 return dataTypes;
? {}
: {
EntryDataType.basic,
EntryDataType.catalog,
};
} }
Future<Set<EntryDataType>> _changeOrientation(Future<Map<String, dynamic>> Function() apply) async { Future<Set<EntryDataType>> _changeOrientation(Future<Map<String, dynamic>> Function() apply) async {
final Set<EntryDataType> dataTypes = {}; final Set<EntryDataType> dataTypes = {};
// when editing a file that has no metadata date, await _missingDateCheckAndExifEdit(dataTypes);
// 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(); final newFields = await apply();
// applying fields is only useful for a smoother visual change, // applying fields is only useful for a smoother visual change,
@ -103,16 +106,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
final Set<EntryDataType> dataTypes = {}; final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> metadata = {}; final Map<MetadataType, dynamic> metadata = {};
// when editing a file that has no metadata date, final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
// 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) { if (canEditIptc) {
final iptc = await metadataFetchService.getIptc(this); final iptc = await metadataFetchService.getIptc(this);
@ -125,8 +119,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
if (canEditXmp) { if (canEditXmp) {
metadata[MetadataType.xmp] = await _editXmp((descriptions) { metadata[MetadataType.xmp] = await _editXmp((descriptions) {
if (missingDate != null) { if (missingDate != null) {
editDateXmp(descriptions, missingDate!); editCreateDateXmp(descriptions, missingDate);
missingDate = null;
} }
editTagsXmp(descriptions, tags); editTagsXmp(descriptions, tags);
}); });
@ -150,22 +143,12 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
final Set<EntryDataType> dataTypes = {}; final Set<EntryDataType> dataTypes = {};
final Map<MetadataType, dynamic> metadata = {}; final Map<MetadataType, dynamic> metadata = {};
// when editing a file that has no metadata date, final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
// 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) { if (canEditXmp) {
metadata[MetadataType.xmp] = await _editXmp((descriptions) { metadata[MetadataType.xmp] = await _editXmp((descriptions) {
if (missingDate != null) { if (missingDate != null) {
editDateXmp(descriptions, missingDate!); editCreateDateXmp(descriptions, missingDate);
missingDate = null;
} }
editRatingXmp(descriptions, rating); editRatingXmp(descriptions, rating);
}); });
@ -190,11 +173,11 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
} }
@visibleForTesting @visibleForTesting
static void editDateXmp(List<XmlNode> descriptions, DateTime date) { static void editCreateDateXmp(List<XmlNode> descriptions, DateTime? date) {
XMP.setAttribute( XMP.setAttribute(
descriptions, descriptions,
XMP.xmpCreateDate, XMP.xmpCreateDate,
XMP.toXmpDate(date), date != null ? XMP.toXmpDate(date) : null,
namespace: Namespaces.xmp, namespace: Namespaces.xmp,
strat: XmpEditStrategy.always, strat: XmpEditStrategy.always,
); );
@ -241,19 +224,69 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
// convenience // convenience
Future<DateTime?> _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<DateTime?> _missingDateCheckAndExifEdit(Set<EntryDataType> dataTypes) async {
if (path == null) return null; if (path == null) return null;
// make sure entry is catalogued before we check whether is has a metadata date // make sure entry is catalogued before we check whether is has a metadata date
if (!isCatalogued) { if (!isCatalogued) {
await catalog(background: false, force: false, persist: true); await catalog(background: false, force: false, persist: true);
} }
final metadataDate = catalogMetadata?.dateMillis; final dateMillis = catalogMetadata?.dateMillis;
if (metadataDate != null && metadataDate > 0) return null; if (dateMillis != null && dateMillis > 0) return null;
late DateTime date;
try { try {
return await File(path!).lastModified(); date = await File(path!).lastModified();
} on FileSystemException catch (_) {} } 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<DateModifier?> _applyDateModifierToEntry(DateModifier modifier) async {
Set<MetadataField> 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<Map<String, String?>> _editXmp(void Function(List<XmlNode> descriptions) apply) async { Future<Map<String, String?>> _editXmp(void Function(List<XmlNode> descriptions) apply) async {

View file

@ -9,6 +9,7 @@ class DateModifier {
MetadataField.exifDateOriginal, MetadataField.exifDateOriginal,
MetadataField.exifDateDigitized, MetadataField.exifDateDigitized,
MetadataField.exifGpsDate, MetadataField.exifGpsDate,
MetadataField.xmpCreateDate,
]; ];
final DateEditAction action; final DateEditAction action;

View file

@ -3,6 +3,7 @@ enum MetadataField {
exifDateOriginal, exifDateOriginal,
exifDateDigitized, exifDateDigitized,
exifGpsDate, exifGpsDate,
xmpCreateDate,
} }
enum DateEditAction { enum DateEditAction {
@ -91,7 +92,19 @@ extension ExtraMetadataType on MetadataType {
} }
extension ExtraMetadataField on MetadataField { 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) { switch (this) {
case MetadataField.exifDate: case MetadataField.exifDate:
return 'DateTime'; return 'DateTime';
@ -101,6 +114,8 @@ extension ExtraMetadataField on MetadataField {
return 'DateTimeDigitized'; return 'DateTimeDigitized';
case MetadataField.exifGpsDate: case MetadataField.exifGpsDate:
return 'GPSDateStamp'; return 'GPSDateStamp';
case MetadataField.xmpCreateDate:
return null;
} }
} }
} }

View file

@ -4,6 +4,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/metadata/enums.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
abstract class MetadataEditService { abstract class MetadataEditService {
@ -11,7 +12,7 @@ abstract class MetadataEditService {
Future<Map<String, dynamic>> flip(AvesEntry entry); Future<Map<String, dynamic>> flip(AvesEntry entry);
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier); Future<Map<String, dynamic>> editExifDate(AvesEntry entry, DateModifier modifier);
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> modifier); Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> modifier);
@ -70,13 +71,13 @@ class PlatformMetadataEditService implements MetadataEditService {
} }
@override @override
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier) async { Future<Map<String, dynamic>> editExifDate(AvesEntry entry, DateModifier modifier) async {
try { try {
final result = await platform.invokeMethod('editDate', <String, dynamic>{ final result = await platform.invokeMethod('editDate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry), 'entry': _toPlatformEntryMap(entry),
'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch, 'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch,
'shiftMinutes': modifier.shiftMinutes, '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<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {

View file

@ -79,6 +79,21 @@ class XMP {
static String toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}'; static String toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}';
static String? getString(
List<XmlNode> 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<String, String> namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); static void _addNamespaces(XmlNode node, Map<String, String> namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri));
// remove elements and attributes // remove elements and attributes

View file

@ -30,11 +30,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
late ValueNotifier<int> _shiftHour, _shiftMinute; late ValueNotifier<int> _shiftHour, _shiftMinute;
late ValueNotifier<String> _shiftSign; late ValueNotifier<String> _shiftSign;
bool _showOptions = false; bool _showOptions = false;
final Set<MetadataField> _fields = { final Set<MetadataField> _fields = {...DateModifier.writableDateFields};
MetadataField.exifDate,
MetadataField.exifDateDigitized,
MetadataField.exifDateOriginal,
};
// use a different shade to avoid having the same background // use a different shade to avoid having the same background
// on the dialog (using the theme `dialogBackgroundColor`) // on the dialog (using the theme `dialogBackgroundColor`)
@ -99,10 +95,10 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
if (_action == DateEditAction.setCustom) _buildSetCustomContent(context), if (_action == DateEditAction.setCustom) _buildSetCustomContent(context),
if (_action == DateEditAction.copyField) _buildCopyFieldContent(context), if (_action == DateEditAction.copyField) _buildCopyFieldContent(context),
if (_action == DateEditAction.shift) _buildShiftContent(context), if (_action == DateEditAction.shift) _buildShiftContent(context),
(_action == DateEditAction.shift || _action == DateEditAction.remove)? _buildDestinationFields(context): const SizedBox(height: 8),
], ],
), ),
), ),
_buildDestinationFields(context),
], ],
actions: [ actions: [
TextButton( TextButton(
@ -135,7 +131,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
final use24hour = context.select<MediaQueryData, bool>((v) => v.alwaysUse24HourFormat); final use24hour = context.select<MediaQueryData, bool>((v) => v.alwaysUse24HourFormat);
return Padding( return Padding(
padding: const EdgeInsets.only(left: 16, top: 4, right: 12), padding: const EdgeInsets.only(left: 16, right: 8),
child: Row( child: Row(
children: [ children: [
Expanded(child: Text(formatDateTime(_setDateTime, locale, use24hour))), Expanded(child: Text(formatDateTime(_setDateTime, locale, use24hour))),
@ -151,7 +147,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
Widget _buildCopyFieldContent(BuildContext context) { Widget _buildCopyFieldContent(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.only(left: 16, top: 0, right: 16),
child: DropdownButton<DateFieldSource>( child: DropdownButton<DateFieldSource>(
items: DateFieldSource.values items: DateFieldSource.values
.map((v) => DropdownMenuItem<DateFieldSource>( .map((v) => DropdownMenuItem<DateFieldSource>(
@ -308,6 +304,8 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
return 'Exif digitized date'; return 'Exif digitized date';
case MetadataField.exifGpsDate: case MetadataField.exifGpsDate:
return 'Exif GPS date'; return 'Exif GPS date';
case MetadataField.xmpCreateDate:
return 'XMP xmp:CreateDate';
} }
} }
@ -337,13 +335,16 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
} }
DateModifier _getModifier() { 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) { switch (_action) {
case DateEditAction.setCustom: case DateEditAction.setCustom:
return DateModifier.setCustom(_fields, _setDateTime); return DateModifier.setCustom(const {}, _setDateTime);
case DateEditAction.copyField: case DateEditAction.copyField:
return DateModifier.copyField(_fields, _copyFieldSource); return DateModifier.copyField(const {}, _copyFieldSource);
case DateEditAction.extractFromTitle: case DateEditAction.extractFromTitle:
return DateModifier.extractFromTitle(_fields); return DateModifier.extractFromTitle(const {});
case DateEditAction.shift: case DateEditAction.shift:
final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1); final shiftTotalMinutes = (_shiftHour.value * 60 + _shiftMinute.value) * (_shiftSign.value == '+' ? 1 : -1);
return DateModifier.shift(_fields, shiftTotalMinutes); return DateModifier.shift(_fields, shiftTotalMinutes);

View file

@ -13,6 +13,15 @@ void main() {
) )
: null; : null;
List<XmlNode> _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 = ''' const inMultiDescriptionRatings = '''
<x:xmpmeta xmlns:x="adobe:ns:meta/"> <x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
@ -79,6 +88,12 @@ void main() {
</x:xmpmeta> </x:xmpmeta>
'''; ''';
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 { test('Set tags without existing XMP', () async {
final modifyDate = DateTime.now(); final modifyDate = DateTime.now();
final xmpDate = XMP.toXmpDate(modifyDate); final xmpDate = XMP.toXmpDate(modifyDate);