#146 editing ratings/tags automatically sets a metadata date via XMP for GIF
This commit is contained in:
parent
f3581562d4
commit
a3e18d3b3a
4 changed files with 164 additions and 115 deletions
|
@ -7,8 +7,6 @@ import 'package:aves/model/entry_cache.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
import 'package:aves/model/metadata/address.dart';
|
import 'package:aves/model/metadata/address.dart';
|
||||||
import 'package:aves/model/metadata/catalog.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/multipage.dart';
|
||||||
import 'package:aves/model/video/metadata.dart';
|
import 'package:aves/model/video/metadata.dart';
|
||||||
import 'package:aves/ref/mime_types.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/services/metadata/svg_metadata_service.dart';
|
||||||
import 'package:aves/theme/format.dart';
|
import 'package:aves/theme/format.dart';
|
||||||
import 'package:aves/utils/change_notifier.dart';
|
import 'package:aves/utils/change_notifier.dart';
|
||||||
import 'package:aves/utils/time_utils.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:country_code/country_code.dart';
|
import 'package:country_code/country_code.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
@ -481,14 +478,14 @@ class AvesEntry {
|
||||||
'width': size.width.ceil(),
|
'width': size.width.ceil(),
|
||||||
'height': size.height.ceil(),
|
'height': size.height.ceil(),
|
||||||
};
|
};
|
||||||
await _applyNewFields(fields, persist: persist);
|
await applyNewFields(fields, persist: persist);
|
||||||
}
|
}
|
||||||
catalogMetadata = CatalogMetadata(contentId: contentId);
|
catalogMetadata = CatalogMetadata(contentId: contentId);
|
||||||
} else {
|
} else {
|
||||||
if (isVideo && (!isSized || durationMillis == 0)) {
|
if (isVideo && (!isSized || durationMillis == 0)) {
|
||||||
// exotic video that is not sized during loading
|
// exotic video that is not sized during loading
|
||||||
final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
|
final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
|
||||||
await _applyNewFields(fields, persist: persist);
|
await applyNewFields(fields, persist: persist);
|
||||||
}
|
}
|
||||||
catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background);
|
catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background);
|
||||||
|
|
||||||
|
@ -585,7 +582,7 @@ class AvesEntry {
|
||||||
}.whereNotNull().where((v) => v.isNotEmpty).join(', ');
|
}.whereNotNull().where((v) => v.isNotEmpty).join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applyNewFields(Map newFields, {required bool persist}) async {
|
Future<void> applyNewFields(Map newFields, {required bool persist}) async {
|
||||||
final oldDateModifiedSecs = this.dateModifiedSecs;
|
final oldDateModifiedSecs = this.dateModifiedSecs;
|
||||||
final oldRotationDegrees = this.rotationDegrees;
|
final oldRotationDegrees = this.rotationDegrees;
|
||||||
final oldIsFlipped = this.isFlipped;
|
final oldIsFlipped = this.isFlipped;
|
||||||
|
@ -646,116 +643,12 @@ class AvesEntry {
|
||||||
|
|
||||||
final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
|
final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
|
||||||
if (updatedEntry != null) {
|
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 catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist);
|
||||||
await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale);
|
await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Set<EntryDataType>> _changeOrientation(Future<Map<String, dynamic>> 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<Set<EntryDataType>> rotate({required bool clockwise}) {
|
|
||||||
return _changeOrientation(() => metadataEditService.rotate(this, clockwise: clockwise));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Set<EntryDataType>> flip() {
|
|
||||||
return _changeOrientation(() => metadataEditService.flip(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (_) {}
|
|
||||||
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<Set<EntryDataType>> 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<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
|
|
||||||
final newFields = await metadataEditService.removeTypes(this, types);
|
|
||||||
return newFields.isEmpty
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
EntryDataType.basic,
|
|
||||||
EntryDataType.catalog,
|
|
||||||
EntryDataType.address,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> delete() {
|
Future<bool> delete() {
|
||||||
final completer = Completer<bool>();
|
final completer = Completer<bool>();
|
||||||
mediaFileService.delete(entries: {this}).listen(
|
mediaFileService.delete(entries: {this}).listen(
|
||||||
|
|
|
@ -1,23 +1,118 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.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/ref/iptc.dart';
|
import 'package:aves/ref/iptc.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
import 'package:aves/services/metadata/xmp.dart';
|
import 'package:aves/services/metadata/xmp.dart';
|
||||||
|
import 'package:aves/utils/time_utils.dart';
|
||||||
import 'package:aves/utils/xmp_utils.dart';
|
import 'package:aves/utils/xmp_utils.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
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 {
|
||||||
|
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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Set<EntryDataType>> rotate({required bool clockwise}) {
|
||||||
|
return _changeOrientation(() => metadataEditService.rotate(this, clockwise: clockwise));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Set<EntryDataType>> flip() {
|
||||||
|
return _changeOrientation(() => metadataEditService.flip(this));
|
||||||
|
}
|
||||||
|
|
||||||
// write:
|
// write:
|
||||||
// - IPTC / keywords, if IPTC exists
|
// - IPTC / keywords, if IPTC exists
|
||||||
// - XMP / dc:subject
|
// - XMP / dc:subject
|
||||||
Future<Set<EntryDataType>> editTags(Set<String> tags) async {
|
Future<Set<EntryDataType>> editTags(Set<String> tags) async {
|
||||||
|
final Set<EntryDataType> dataTypes = {};
|
||||||
final Map<MetadataType, dynamic> metadata = {};
|
final Map<MetadataType, dynamic> 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) {
|
if (canEditIptc) {
|
||||||
final iptc = await metadataFetchService.getIptc(this);
|
final iptc = await metadataFetchService.getIptc(this);
|
||||||
|
@ -28,7 +123,13 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canEditXmp) {
|
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);
|
final newFields = await metadataEditService.editMetadata(this, metadata);
|
||||||
|
@ -46,12 +147,28 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
// - Exif / Rating
|
// - Exif / Rating
|
||||||
// - Exif / RatingPercent
|
// - Exif / RatingPercent
|
||||||
Future<Set<EntryDataType>> editRating(int? rating) async {
|
Future<Set<EntryDataType>> editRating(int? rating) async {
|
||||||
|
final Set<EntryDataType> dataTypes = {};
|
||||||
final Map<MetadataType, dynamic> metadata = {};
|
final Map<MetadataType, dynamic> 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) {
|
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);
|
final newFields = await metadataEditService.editMetadata(this, metadata);
|
||||||
|
@ -61,6 +178,28 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
return dataTypes;
|
return dataTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
|
||||||
|
final newFields = await metadataEditService.removeTypes(this, types);
|
||||||
|
return newFields.isEmpty
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
EntryDataType.basic,
|
||||||
|
EntryDataType.catalog,
|
||||||
|
EntryDataType.address,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static void editDateXmp(List<XmlNode> descriptions, DateTime date) {
|
||||||
|
XMP.setAttribute(
|
||||||
|
descriptions,
|
||||||
|
XMP.xmpCreateDate,
|
||||||
|
XMP.toXmpDate(date),
|
||||||
|
namespace: Namespaces.xmp,
|
||||||
|
strat: XmpEditStrategy.always,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static void editTagsIptc(List<Map<String, dynamic>> iptc, Set<String> tags) {
|
static void editTagsIptc(List<Map<String, dynamic>> iptc, Set<String> tags) {
|
||||||
iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag);
|
iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag);
|
||||||
|
@ -102,6 +241,21 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
||||||
|
|
||||||
// convenience
|
// convenience
|
||||||
|
|
||||||
|
Future<DateTime?> _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<Map<String, String?>> _editXmp(void Function(List<XmlNode> descriptions) apply) async {
|
Future<Map<String, String?>> _editXmp(void Function(List<XmlNode> descriptions) apply) async {
|
||||||
final xmp = await metadataFetchService.getXmp(this);
|
final xmp = await metadataFetchService.getXmp(this);
|
||||||
final xmpString = xmp?.xmpString;
|
final xmpString = xmp?.xmpString;
|
||||||
|
|
|
@ -37,6 +37,7 @@ class XMP {
|
||||||
// attributes
|
// attributes
|
||||||
static const xXmptk = 'xmptk';
|
static const xXmptk = 'xmptk';
|
||||||
static const rdfAbout = 'about';
|
static const rdfAbout = 'about';
|
||||||
|
static const xmpCreateDate = 'CreateDate';
|
||||||
static const xmpMetadataDate = 'MetadataDate';
|
static const xmpMetadataDate = 'MetadataDate';
|
||||||
static const xmpModifyDate = 'ModifyDate';
|
static const xmpModifyDate = 'ModifyDate';
|
||||||
static const xmpNoteHasExtendedXMP = 'HasExtendedXMP';
|
static const xmpNoteHasExtendedXMP = 'HasExtendedXMP';
|
||||||
|
|
|
@ -6,6 +6,7 @@ import 'package:aves/model/actions/entry_actions.dart';
|
||||||
import 'package:aves/model/actions/move_type.dart';
|
import 'package:aves/model/actions/move_type.dart';
|
||||||
import 'package:aves/model/device.dart';
|
import 'package:aves/model/device.dart';
|
||||||
import 'package:aves/model/entry.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/filters/album.dart';
|
||||||
import 'package:aves/model/highlight.dart';
|
import 'package:aves/model/highlight.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
|
|
Loading…
Reference in a new issue