284 lines
9.5 KiB
Dart
284 lines
9.5 KiB
Dart
import 'package:aves/ref/locales.dart';
|
|
import 'package:aves/ref/metadata/xmp.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:xml/xml.dart';
|
|
|
|
class XMP {
|
|
static const xmlnsPrefix = 'xmlns';
|
|
static const propNamespaceSeparator = ':';
|
|
static const structFieldSeparator = '/';
|
|
|
|
static final _defaultPrefixes = {
|
|
XmpNamespaces.gContainer: 'Container',
|
|
XmpNamespaces.dc: 'dc',
|
|
XmpNamespaces.gCamera: 'GCamera',
|
|
XmpNamespaces.microsoftPhoto: 'MicrosoftPhoto',
|
|
XmpNamespaces.rdf: 'rdf',
|
|
XmpNamespaces.x: 'x',
|
|
XmpNamespaces.xmp: 'xmp',
|
|
XmpNamespaces.xmpGImg: 'xmpGImg',
|
|
XmpNamespaces.xmpNote: 'xmpNote',
|
|
};
|
|
|
|
static String prefixOf(String ns) => _defaultPrefixes[ns] ?? '';
|
|
|
|
static const nsRdf = XmpNamespaces.rdf;
|
|
static const nsX = XmpNamespaces.x;
|
|
static const nsXmp = XmpNamespaces.xmp;
|
|
|
|
// for `rdf:Description` node only
|
|
static bool _hasMeaningfulChildren(XmlNode node) => node.children.any((v) => v.nodeType != XmlNodeType.TEXT || v.innerText.trim().isNotEmpty);
|
|
|
|
// for `rdf:Description` node only
|
|
static bool _hasMeaningfulAttributes(XmlNode description) {
|
|
final hasMeaningfulAttributes = description.attributes.any((v) {
|
|
switch (v.name.local) {
|
|
case XmpAttributes.rdfAbout:
|
|
case XmpAttributes.xmpMetadataDate:
|
|
case XmpAttributes.xmpModifyDate:
|
|
return false;
|
|
default:
|
|
switch (v.name.prefix) {
|
|
case xmlnsPrefix:
|
|
return false;
|
|
default:
|
|
// if the attribute got defined with the prefix as part of the name,
|
|
// the prefix is not recognized as such, so we check the full name
|
|
return !v.name.qualified.startsWith(xmlnsPrefix);
|
|
}
|
|
}
|
|
});
|
|
return hasMeaningfulAttributes;
|
|
}
|
|
|
|
// returns time zone designator, formatted as `Z` or `+hh:mm` or `-hh:mm`
|
|
// as of intl v0.17.0, formatting time zone offset is not implemented
|
|
static String _xmpTimeZoneDesignator(DateTime date) {
|
|
final offsetMinutes = date.timeZoneOffset.inMinutes;
|
|
final abs = offsetMinutes.abs();
|
|
final h = abs ~/ Duration.minutesPerHour;
|
|
final m = abs % Duration.minutesPerHour;
|
|
return '${offsetMinutes.isNegative ? '-' : '+'}${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
static String toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss', asciiLocale).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
|
|
static bool removeElements(List<XmlNode> nodes, String name, String namespace) {
|
|
var removed = false;
|
|
nodes.forEach((node) {
|
|
final elements = node.findElements(name, namespace: namespace).toSet();
|
|
if (elements.isNotEmpty) {
|
|
elements.forEach(node.children.remove);
|
|
removed = true;
|
|
}
|
|
|
|
if (node.getAttributeNode(name, namespace: namespace) != null) {
|
|
node.removeAttribute(name, namespace: namespace);
|
|
removed = true;
|
|
}
|
|
});
|
|
return removed;
|
|
}
|
|
|
|
// remove attribute/element from all nodes, and set attribute with new value, if any, in the first node
|
|
static bool setAttribute(
|
|
List<XmlNode> nodes,
|
|
String name,
|
|
String? value, {
|
|
required String namespace,
|
|
required XmpEditStrategy strat,
|
|
}) {
|
|
final removed = removeElements(nodes, name, namespace);
|
|
|
|
if (value == null) return removed;
|
|
|
|
bool modified = removed;
|
|
if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) {
|
|
final node = nodes.first;
|
|
_addNamespaces(node, {namespace: prefixOf(namespace)});
|
|
|
|
// use qualified name, otherwise the namespace prefix is not added
|
|
final qualifiedName = '${prefixOf(namespace)}$propNamespaceSeparator$name';
|
|
node.setAttribute(qualifiedName, value);
|
|
modified = true;
|
|
}
|
|
|
|
return modified;
|
|
}
|
|
|
|
// remove attribute/element from all nodes, and create element with new value, if any, in the first node
|
|
static void setElement(
|
|
List<XmlNode> nodes,
|
|
String name,
|
|
String? value, {
|
|
required String namespace,
|
|
required XmpEditStrategy strat,
|
|
}) {
|
|
final removed = removeElements(nodes, name, namespace);
|
|
|
|
if (value == null) return;
|
|
|
|
if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) {
|
|
final node = nodes.first;
|
|
_addNamespaces(node, {namespace: prefixOf(namespace)});
|
|
|
|
final builder = XmlBuilder();
|
|
builder.namespace(namespace, prefixOf(namespace));
|
|
builder.element(name, namespace: namespace, nest: () {
|
|
builder.text(value);
|
|
});
|
|
node.children.add(builder.buildFragment());
|
|
}
|
|
}
|
|
|
|
// remove bag from all nodes, and create bag with new values, if any, in the first node
|
|
static bool setStringBag(
|
|
List<XmlNode> nodes,
|
|
String name,
|
|
Set<String> values, {
|
|
required String namespace,
|
|
required XmpEditStrategy strat,
|
|
}) {
|
|
// remove existing
|
|
final removed = removeElements(nodes, name, namespace);
|
|
|
|
if (values.isEmpty) return removed;
|
|
|
|
bool modified = removed;
|
|
if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) {
|
|
final node = nodes.first;
|
|
_addNamespaces(node, {namespace: prefixOf(namespace)});
|
|
|
|
// add new bag
|
|
final rootBuilder = XmlBuilder();
|
|
rootBuilder.namespace(namespace, prefixOf(namespace));
|
|
rootBuilder.element(name, namespace: namespace);
|
|
node.children.add(rootBuilder.buildFragment());
|
|
|
|
final bagBuilder = XmlBuilder();
|
|
bagBuilder.namespace(nsRdf, prefixOf(nsRdf));
|
|
bagBuilder.element('Bag', namespace: nsRdf, nest: () {
|
|
values.forEach((v) {
|
|
bagBuilder.element('li', namespace: nsRdf, nest: v);
|
|
});
|
|
});
|
|
node.children.last.children.add(bagBuilder.buildFragment());
|
|
modified = true;
|
|
}
|
|
|
|
return modified;
|
|
}
|
|
|
|
static Future<String?> edit(
|
|
String? xmpString,
|
|
String toolkit,
|
|
bool Function(List<XmlNode> descriptions) apply, {
|
|
DateTime? modifyDate,
|
|
}) async {
|
|
XmlDocument? xmpDoc;
|
|
if (xmpString != null) {
|
|
xmpDoc = XmlDocument.parse(xmpString);
|
|
}
|
|
if (xmpDoc == null) {
|
|
final builder = XmlBuilder();
|
|
builder.namespace(nsX, prefixOf(nsX));
|
|
builder.element(XmpElements.xXmpmeta, namespace: nsX, namespaces: {
|
|
nsX: prefixOf(nsX),
|
|
}, attributes: {
|
|
'${prefixOf(nsX)}$propNamespaceSeparator${XmpAttributes.xXmptk}': toolkit,
|
|
});
|
|
xmpDoc = builder.buildDocument();
|
|
}
|
|
|
|
final root = xmpDoc.rootElement;
|
|
XmlNode? rdf = root.getElement(XmpElements.rdfRoot, namespace: nsRdf);
|
|
if (rdf == null) {
|
|
final builder = XmlBuilder();
|
|
builder.namespace(nsRdf, prefixOf(nsRdf));
|
|
builder.element(XmpElements.rdfRoot, namespace: nsRdf, namespaces: {
|
|
nsRdf: prefixOf(nsRdf),
|
|
});
|
|
// get element because doc fragment cannot be used to edit
|
|
root.children.add(builder.buildFragment());
|
|
rdf = root.getElement(XmpElements.rdfRoot, namespace: nsRdf)!;
|
|
}
|
|
|
|
// content can be split in multiple `rdf:Description` elements
|
|
List<XmlNode> descriptions = rdf.children.where((node) {
|
|
return node is XmlElement && node.name.local == XmpElements.rdfDescription && node.name.namespaceUri == nsRdf;
|
|
}).toList();
|
|
|
|
if (descriptions.isEmpty) {
|
|
final builder = XmlBuilder();
|
|
builder.namespace(nsRdf, prefixOf(nsRdf));
|
|
builder.element(XmpElements.rdfDescription, namespace: nsRdf, attributes: {
|
|
'${prefixOf(nsRdf)}$propNamespaceSeparator${XmpAttributes.rdfAbout}': '',
|
|
});
|
|
rdf.children.add(builder.buildFragment());
|
|
// get element because doc fragment cannot be used to edit
|
|
descriptions.add(rdf.getElement(XmpElements.rdfDescription, namespace: nsRdf)!);
|
|
}
|
|
final modified = apply(descriptions);
|
|
|
|
// clean description nodes with no children
|
|
descriptions.where((v) => !_hasMeaningfulChildren(v)).forEach((v) => v.children.clear());
|
|
|
|
// remove superfluous description nodes
|
|
rdf.children.removeWhere((v) => !_hasMeaningfulChildren(v) && !_hasMeaningfulAttributes(v));
|
|
|
|
if (rdf.children.isNotEmpty) {
|
|
if (modified) {
|
|
_addNamespaces(descriptions.first, {nsXmp: prefixOf(nsXmp)});
|
|
final xmpDate = toXmpDate(modifyDate ?? DateTime.now());
|
|
setAttribute(descriptions, XmpAttributes.xmpMetadataDate, xmpDate, namespace: nsXmp, strat: XmpEditStrategy.always);
|
|
setAttribute(descriptions, XmpAttributes.xmpModifyDate, xmpDate, namespace: nsXmp, strat: XmpEditStrategy.always);
|
|
}
|
|
} else {
|
|
// clear XMP if there are no attributes or elements worth preserving
|
|
xmpDoc = null;
|
|
}
|
|
|
|
return xmpDoc?.toXmlString();
|
|
}
|
|
|
|
static String? toMsPhotoRating(int? rating) {
|
|
if (rating == null) return null;
|
|
switch (rating) {
|
|
case 5:
|
|
return '99';
|
|
case 4:
|
|
return '75';
|
|
case 3:
|
|
return '50';
|
|
case 2:
|
|
return '25';
|
|
case 1:
|
|
return '1';
|
|
case 0:
|
|
return null;
|
|
case -1:
|
|
return '-1';
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
enum XmpEditStrategy { always, updateIfPresent }
|