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; } // return 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').format(date)}${_xmpTimeZoneDesignator(date)}'; static String? getString( List 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 namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); // remove elements and attributes static bool removeElements(List 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 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 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 nodes, String name, Set 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 edit( String? xmpString, String toolkit, bool Function(List 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 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 }