info: edit date for GIF
This commit is contained in:
parent
a3e18d3b3a
commit
b94097bda7
9 changed files with 180 additions and 99 deletions
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -14,66 +14,69 @@ import 'package:package_info_plus/package_info_plus.dart';
|
|||
import 'package:xml/xml.dart';
|
||||
|
||||
extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||
Future<Set<EntryDataType>> 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<Set<EntryDataType>> editDate(DateModifier userModifier) async {
|
||||
final Set<EntryDataType> 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<Set<EntryDataType>> _changeOrientation(Future<Map<String, dynamic>> Function() apply) async {
|
||||
final Set<EntryDataType> 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<EntryDataType> dataTypes = {};
|
||||
final Map<MetadataType, dynamic> 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<EntryDataType> dataTypes = {};
|
||||
final Map<MetadataType, dynamic> 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<XmlNode> descriptions, DateTime date) {
|
||||
static void editCreateDateXmp(List<XmlNode> 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<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;
|
||||
|
||||
// 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<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 {
|
||||
|
|
|
@ -9,6 +9,7 @@ class DateModifier {
|
|||
MetadataField.exifDateOriginal,
|
||||
MetadataField.exifDateDigitized,
|
||||
MetadataField.exifGpsDate,
|
||||
MetadataField.xmpCreateDate,
|
||||
];
|
||||
|
||||
final DateEditAction action;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<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);
|
||||
|
||||
|
@ -70,13 +71,13 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier) async {
|
||||
Future<Map<String, dynamic>> editExifDate(AvesEntry entry, DateModifier modifier) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('editDate', <String, dynamic>{
|
||||
'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<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
|
|
|
@ -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<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));
|
||||
|
||||
// remove elements and attributes
|
||||
|
|
|
@ -30,11 +30,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
late ValueNotifier<int> _shiftHour, _shiftMinute;
|
||||
late ValueNotifier<String> _shiftSign;
|
||||
bool _showOptions = false;
|
||||
final Set<MetadataField> _fields = {
|
||||
MetadataField.exifDate,
|
||||
MetadataField.exifDateDigitized,
|
||||
MetadataField.exifDateOriginal,
|
||||
};
|
||||
final Set<MetadataField> _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<EditEntryDateDialog> {
|
|||
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<EditEntryDateDialog> {
|
|||
final use24hour = context.select<MediaQueryData, bool>((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<EditEntryDateDialog> {
|
|||
|
||||
Widget _buildCopyFieldContent(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.only(left: 16, top: 0, right: 16),
|
||||
child: DropdownButton<DateFieldSource>(
|
||||
items: DateFieldSource.values
|
||||
.map((v) => DropdownMenuItem<DateFieldSource>(
|
||||
|
@ -308,6 +304,8 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
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<EditEntryDateDialog> {
|
|||
}
|
||||
|
||||
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);
|
||||
|
|
|
@ -13,6 +13,15 @@ void main() {
|
|||
)
|
||||
: 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 = '''
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
|
@ -79,6 +88,12 @@ void main() {
|
|||
</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 {
|
||||
final modifyDate = DateTime.now();
|
||||
final xmpDate = XMP.toXmpDate(modifyDate);
|
||||
|
|
Loading…
Reference in a new issue