/**
* Copyright (c) Codice Foundation
* <p>
* This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or any later version.
* <p>
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
* even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details. A copy of the GNU Lesser General Public License
* is distributed along with this program and can be found at
* <http://www.gnu.org/licenses/lgpl.html>.
**/
package org.codice.ddf.spatial.kml.transformer;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.io.StringWriter;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
import java.util.UUID;
import javax.activation.MimeType;
import javax.activation.MimeTypeParseException;
import javax.security.auth.Subject;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBElement;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Marshaller;
import javax.xml.bind.Unmarshaller;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import org.apache.commons.lang3.StringUtils;
import org.osgi.framework.BundleContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.github.jknack.handlebars.Handlebars;
import com.github.jknack.handlebars.Template;
import com.github.jknack.handlebars.io.ClassPathTemplateLoader;
import com.vividsolutions.jts.geom.GeometryCollection;
import com.vividsolutions.jts.geom.LineString;
import com.vividsolutions.jts.geom.Point;
import com.vividsolutions.jts.geom.Polygon;
import com.vividsolutions.jts.io.ParseException;
import com.vividsolutions.jts.io.WKTReader;
import ddf.action.ActionProvider;
import ddf.catalog.data.BinaryContent;
import ddf.catalog.data.Metacard;
import ddf.catalog.data.Result;
import ddf.catalog.data.impl.BinaryContentImpl;
import ddf.catalog.data.impl.MetacardImpl;
import ddf.catalog.operation.SourceResponse;
import ddf.catalog.transform.CatalogTransformerException;
import de.micromata.opengis.kml.v_2_2_0.Coordinate;
import de.micromata.opengis.kml.v_2_2_0.Document;
import de.micromata.opengis.kml.v_2_2_0.Geometry;
import de.micromata.opengis.kml.v_2_2_0.Kml;
import de.micromata.opengis.kml.v_2_2_0.KmlFactory;
import de.micromata.opengis.kml.v_2_2_0.Placemark;
import de.micromata.opengis.kml.v_2_2_0.Style;
import de.micromata.opengis.kml.v_2_2_0.StyleSelector;
import de.micromata.opengis.kml.v_2_2_0.TimeSpan;
/**
* The base Transformer for handling KML requests to take a {@link Metacard} or
* {@link SourceResponse} and produce a KML representation. This service attempts to first locate a
* {@link KMLEntryTransformer} for a given {@link Metacard} based on the metadata-content-type. If
* no {@link KMLEntryTransformer} can be found, the default transformation is performed.
*
* @author Ashraf Barakat, Ian Barnett, Keith C Wire
*
*/
public class KMLTransformerImpl implements KMLTransformer {
private static final String UTF_8 = "UTF-8";
private static final String KML_RESPONSE_QUEUE_PREFIX = "Results (";
private static final String SERVICES_REST = "/services/catalog/";
private static final String CLOSE_PARENTHESIS = ")";
private static final String TEMPLATE_DIRECTORY = "/templates";
private static final String TEMPLATE_SUFFIX = ".hbt";
private static final String DESCRIPTION_TEMPLATE = "description";
private static final Logger LOGGER = LoggerFactory.getLogger(KMLTransformerImpl.class);
protected static final MimeType KML_MIMETYPE = new MimeType();
private static List<StyleSelector> defaultStyle = new ArrayList<StyleSelector>();
static {
try {
KML_MIMETYPE.setPrimaryType("application");
KML_MIMETYPE.setSubType("vnd.google-earth.kml+xml");
} catch (MimeTypeParseException e) {
LOGGER.info("Unable to parse KML MimeType.", e);
}
}
protected BundleContext context;
private JAXBContext jaxbContext;
private ClassPathTemplateLoader templateLoader;
private KmlStyleMap styleMapper;
private DescriptionTemplateHelper templateHelper;
public KMLTransformerImpl(BundleContext bundleContext, String defaultStylingName,
KmlStyleMap mapper, ActionProvider actionProvider) {
this.context = bundleContext;
this.styleMapper = mapper;
this.templateHelper = new DescriptionTemplateHelper(actionProvider);
URL stylingUrl = context.getBundle()
.getResource(defaultStylingName);
Unmarshaller unmarshaller = null;
try {
this.jaxbContext = JAXBContext.newInstance(Kml.class);
unmarshaller = jaxbContext.createUnmarshaller();
} catch (JAXBException e) {
LOGGER.info("Unable to create JAXB Context. Setting to null.");
this.jaxbContext = null;
}
try {
if (unmarshaller != null) {
LOGGER.debug("Reading in KML Style");
XMLInputFactory xmlInputFactory = XMLInputFactory.newFactory();
xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES,
false);
xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xmlInputFactory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
XMLStreamReader xmlStreamReader =
xmlInputFactory.createXMLStreamReader(stylingUrl.openStream());
JAXBElement<Kml> jaxbKmlStyle =
unmarshaller.unmarshal(xmlStreamReader,
Kml.class);
Kml kml = jaxbKmlStyle.getValue();
if (kml.getFeature() != null) {
defaultStyle = kml.getFeature()
.getStyleSelector();
}
}
} catch (JAXBException | XMLStreamException e) {
LOGGER.debug("Exception while unmarshalling default style resource.", e);
} catch (IOException e) {
LOGGER.debug("Exception while opening default style resource.", e);
}
templateLoader = new ClassPathTemplateLoader();
templateLoader.setPrefix(TEMPLATE_DIRECTORY);
templateLoader.setSuffix(TEMPLATE_SUFFIX);
}
/**
* Encapsulate the kml content (placemarks, etc.) with a style in a KML Document element If
* either content or style are null, they will be in the resulting Document
*
* @param kml
* @param style
* @param documentId
* which should be the metacard id
* @return KML DocumentType element with style and content
*/
public static Document encloseDoc(Placemark placemark, Style style, String documentId,
String docName) throws IllegalArgumentException {
Document document = KmlFactory.createDocument();
document.setId(documentId);
document.setOpen(true);
document.setName(docName);
if (style != null) {
document.getStyleSelector()
.add(style);
}
if (placemark != null) {
document.getFeature()
.add(placemark);
}
return document;
}
/**
* Wrap KML document with the opening and closing kml tags
*
* @param document
* @param folderId
* which should be the subscription id if it exists
* @return completed KML
*/
public static Kml encloseKml(Document doc, String docId, String docName) {
Kml kml = KmlFactory.createKml();
if (doc != null) {
kml.setFeature(doc);
doc.setId(docId); // Id should be subscription id
doc.setName(docName);
doc.setOpen(false);
}
return kml;
}
/**
* This will return a KML Placemark (i.e. there are no kml tags)
* {@code
* <KML> ---> not included
* <Placemark> ---> What is returned from this method
* ... ---> What is returned from this method
* </Placemark> ---> What is returned from this method
* </KML> ---> not included
* }
*
* @param user
* @param entry
* - the {@link Metacard} to be transformed
* @param arguments
* - additional arguments to assist in the transformation
* @return Placemark - kml object containing transformed content
*
* @throws CatalogTransformerException
*/
@Override
public Placemark transformEntry(Subject user, Metacard entry,
Map<String, Serializable> arguments) throws CatalogTransformerException {
String urlToMetacard = null;
if (arguments == null) {
arguments = new HashMap<String, Serializable>();
}
String incomingRestUriAbsolutePathString = (String) arguments.get("url");
if (incomingRestUriAbsolutePathString != null) {
try {
URI incomingRestUri = new URI(incomingRestUriAbsolutePathString);
URI officialRestUri = new URI(incomingRestUri.getScheme(),
null,
incomingRestUri.getHost(),
incomingRestUri.getPort(),
SERVICES_REST + "/" + entry.getId(),
null,
null);
urlToMetacard = officialRestUri.toString();
} catch (URISyntaxException e) {
LOGGER.info("bad url passed in, using request url for kml href.", e);
urlToMetacard = incomingRestUriAbsolutePathString;
}
LOGGER.debug("REST URL: {}", urlToMetacard);
}
return performDefaultTransformation(entry, incomingRestUriAbsolutePathString);
}
/**
* The default Transformation from a {@link Metacard} to a KML {@link Placemark}. Protected to
* easily allow other default transformations.
*
* @param entry
* - the {@link Metacard} to transform.
* @param urlToMetacard
* @return
* @throws javax.xml.transform.TransformerException
*/
protected Placemark performDefaultTransformation(Metacard entry, String url)
throws CatalogTransformerException {
// wrap metacard to work around classLoader/reflection issues
entry = new MetacardImpl(entry);
Placemark kmlPlacemark = KmlFactory.createPlacemark();
kmlPlacemark.setId("Placemark-" + entry.getId());
kmlPlacemark.setName(entry.getTitle());
DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
String effectiveTime = null;
if (entry.getEffectiveDate() == null) {
effectiveTime = dateFormat.format(new Date());
} else {
effectiveTime = dateFormat.format(entry.getEffectiveDate());
}
TimeSpan timeSpan = KmlFactory.createTimeSpan();
timeSpan.setBegin(effectiveTime);
kmlPlacemark.setTimePrimitive(timeSpan);
kmlPlacemark.setGeometry(getKmlGeoFromWkt(entry.getLocation()));
String description = entry.getTitle();
Handlebars handlebars = new Handlebars(templateLoader);
handlebars.registerHelpers(templateHelper);
try {
Template template = handlebars.compile(DESCRIPTION_TEMPLATE);
description = template.apply(new HandlebarsMetacard(entry));
LOGGER.debug(description);
} catch (IOException e) {
LOGGER.debug("Failed to apply description Template", e);
}
kmlPlacemark.setDescription(description);
String styleUrl = styleMapper.getStyleForMetacard(entry);
if (StringUtils.isNotBlank(styleUrl)) {
kmlPlacemark.setStyleUrl(styleUrl);
}
return kmlPlacemark;
}
private Geometry getKmlGeoFromWkt(final String wkt) throws CatalogTransformerException {
if (StringUtils.isBlank(wkt)) {
throw new CatalogTransformerException(
"WKT was null or empty. Unable to preform KML Transform on Metacard.");
}
com.vividsolutions.jts.geom.Geometry geo = readGeoFromWkt(wkt);
Geometry kmlGeo = createKmlGeo(geo);
if (!Point.class.getSimpleName()
.equals(geo.getGeometryType())) {
kmlGeo = addPointToKmlGeo(kmlGeo, geo.getCoordinate());
}
return kmlGeo;
}
private Geometry createKmlGeo(com.vividsolutions.jts.geom.Geometry geo)
throws CatalogTransformerException {
Geometry kmlGeo = null;
if (Point.class.getSimpleName()
.equals(geo.getGeometryType())) {
Point jtsPoint = (Point) geo;
kmlGeo = KmlFactory.createPoint()
.addToCoordinates(jtsPoint.getX(), jtsPoint.getY());
} else if (LineString.class.getSimpleName()
.equals(geo.getGeometryType())) {
LineString jtsLS = (LineString) geo;
de.micromata.opengis.kml.v_2_2_0.LineString kmlLS = KmlFactory.createLineString();
List<Coordinate> kmlCoords = kmlLS.createAndSetCoordinates();
for (com.vividsolutions.jts.geom.Coordinate coord : jtsLS.getCoordinates()) {
kmlCoords.add(new Coordinate(coord.x, coord.y));
}
kmlGeo = kmlLS;
} else if (Polygon.class.getSimpleName()
.equals(geo.getGeometryType())) {
Polygon jtsPoly = (Polygon) geo;
de.micromata.opengis.kml.v_2_2_0.Polygon kmlPoly = KmlFactory.createPolygon();
List<Coordinate> kmlCoords = kmlPoly.createAndSetOuterBoundaryIs()
.createAndSetLinearRing()
.createAndSetCoordinates();
for (com.vividsolutions.jts.geom.Coordinate coord : jtsPoly.getCoordinates()) {
kmlCoords.add(new Coordinate(coord.x, coord.y));
}
kmlGeo = kmlPoly;
} else if (geo instanceof GeometryCollection) {
List<Geometry> geos = new ArrayList<Geometry>();
for (int xx = 0; xx < geo.getNumGeometries(); xx++) {
geos.add(createKmlGeo(geo.getGeometryN(xx)));
}
kmlGeo = KmlFactory.createMultiGeometry()
.withGeometry(geos);
} else {
throw new CatalogTransformerException(
"Unknown / Unsupported Geometry Type '" + geo.getGeometryType()
+ "'. Unale to preform KML Transform.");
}
return kmlGeo;
}
private com.vividsolutions.jts.geom.Geometry readGeoFromWkt(final String wkt)
throws CatalogTransformerException {
WKTReader reader = new WKTReader();
try {
return reader.read(wkt);
} catch (ParseException e) {
throw new CatalogTransformerException("Unable to parse WKT to Geometry.", e);
}
}
private Geometry addPointToKmlGeo(Geometry kmlGeo,
com.vividsolutions.jts.geom.Coordinate vertex) {
if (null != vertex) {
de.micromata.opengis.kml.v_2_2_0.Point kmlPoint = KmlFactory.createPoint()
.addToCoordinates(vertex.x, vertex.y);
return KmlFactory.createMultiGeometry()
.addToGeometry(kmlPoint)
.addToGeometry(kmlGeo);
} else {
return null;
}
}
@Override
public BinaryContent transform(Metacard metacard, Map<String, Serializable> arguments)
throws CatalogTransformerException {
try {
Placemark placemark = transformEntry(null, metacard, arguments);
if (placemark.getStyleSelector()
.isEmpty() && StringUtils.isBlank(placemark.getStyleUrl())) {
placemark.getStyleSelector()
.addAll(defaultStyle);
}
Kml kml = KmlFactory.createKml()
.withFeature(placemark);
String transformedKmlString = marshalKml(kml);
InputStream kmlInputStream = new ByteArrayInputStream(transformedKmlString.getBytes(
StandardCharsets.UTF_8));
return new BinaryContentImpl(kmlInputStream, KML_MIMETYPE);
} catch (Exception e) {
LOGGER.debug("Error transforming metacard ({}) to KML: {}",
metacard.getId(),
e.getMessage());
throw new CatalogTransformerException("Error transforming metacard to KML.", e);
}
}
@Override
public BinaryContent transform(SourceResponse upstreamResponse,
Map<String, Serializable> arguments) throws CatalogTransformerException {
LOGGER.trace("ENTERING: ResponseQueue transform");
if (arguments == null) {
LOGGER.debug("Null arguments, unable to complete transform");
throw new CatalogTransformerException("Unable to complete transform without arguments");
}
String docId = UUID.randomUUID()
.toString();
String restUriAbsolutePath = (String) arguments.get("url");
LOGGER.debug("rest string url arg: {}", restUriAbsolutePath);
// Transform Metacards to KML
Document kmlDoc = KmlFactory.createDocument();
boolean needDefaultStyle = false;
for (Result result : upstreamResponse.getResults()) {
try {
Placemark placemark = transformEntry(null, result.getMetacard(), arguments);
if (placemark.getStyleSelector()
.isEmpty() && StringUtils.isEmpty(placemark.getStyleUrl())) {
placemark.setStyleUrl("#default");
needDefaultStyle = true;
}
kmlDoc.getFeature()
.add(placemark);
} catch (CatalogTransformerException e) {
LOGGER.debug(
"Error transforming current metacard ({}) to KML and will continue with remaining query responses.",
result.getMetacard()
.getId(), e);
}
}
if (needDefaultStyle) {
kmlDoc.getStyleSelector()
.addAll(defaultStyle);
}
Kml kmlResult = encloseKml(kmlDoc,
docId,
KML_RESPONSE_QUEUE_PREFIX + kmlDoc.getFeature()
.size() + CLOSE_PARENTHESIS);
String transformedKml = marshalKml(kmlResult);
InputStream kmlInputStream = new ByteArrayInputStream(transformedKml.getBytes(
StandardCharsets.UTF_8));
LOGGER.trace("EXITING: ResponseQueue transform");
return new BinaryContentImpl(kmlInputStream, KML_MIMETYPE);
}
private String marshalKml(Kml kmlResult) {
String kmlResultString = null;
StringWriter writer = new StringWriter();
try {
Marshaller marshaller = jaxbContext.createMarshaller();
marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.FALSE);
marshaller.setProperty(Marshaller.JAXB_ENCODING, UTF_8);
marshaller.marshal(kmlResult, writer);
} catch (JAXBException e) {
LOGGER.debug("Failed to marshal KML: ", e);
}
kmlResultString = writer.toString();
return kmlResultString;
}
}