package cgeo.geocaching.export;
import cgeo.geocaching.enumerations.CacheAttribute;
import cgeo.geocaching.enumerations.LoadFlags;
import cgeo.geocaching.location.Geopoint;
import cgeo.geocaching.log.LogEntry;
import cgeo.geocaching.models.Geocache;
import cgeo.geocaching.models.Trackable;
import cgeo.geocaching.models.Waypoint;
import cgeo.geocaching.settings.Settings;
import cgeo.geocaching.storage.DataStore;
import cgeo.geocaching.utils.Log;
import cgeo.geocaching.utils.SynchronizedDateFormat;
import cgeo.geocaching.utils.TextUtils;
import cgeo.geocaching.utils.XmlUtils;
import cgeo.org.kxml2.io.KXmlSerializer;
import android.support.annotation.NonNull;
import java.io.IOException;
import java.io.Writer;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.CharEncoding;
import org.apache.commons.lang3.StringUtils;
import org.xmlpull.v1.XmlSerializer;
public final class GpxSerializer {
private static final SynchronizedDateFormat dateFormatZ = new SynchronizedDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
private static final String PREFIX_XSI = "xsi";
private static final String NS_XSI = "http://www.w3.org/2001/XMLSchema-instance";
private static final String PREFIX_GPX = "";
private static final String NS_GPX = "http://www.topografix.com/GPX/1/0";
private static final String GPX_SCHEMA = NS_GPX + "/gpx.xsd";
private static final String PREFIX_GROUNDSPEAK = "groundspeak";
private static final String NS_GROUNDSPEAK = "http://www.groundspeak.com/cache/1/0/1";
private static final String GROUNDSPEAK_SCHEMA = NS_GROUNDSPEAK + "/cache.xsd";
private static final String PREFIX_GSAK = "gsak";
private static final String NS_GSAK = "http://www.gsak.net/xmlv1/6";
private static final String GSAK_SCHEMA = NS_GSAK + "/gsak.xsd";
private static final String PREFIX_CGEO = "cgeo";
private static final String NS_CGEO = "http://www.cgeo.org/wptext/1/0";
/**
* During the export, only this number of geocaches is fully loaded into memory.
*/
public static final int CACHES_PER_BATCH = 100;
/**
* counter for exported caches, used for progress reporting
*/
private int countExported;
private ProgressListener progressListener;
private final XmlSerializer gpx = new KXmlSerializer();
protected interface ProgressListener {
void publishProgress(int countExported);
}
public void writeGPX(@NonNull final List<String> allGeocodesIn, final Writer writer, final ProgressListener progressListener) throws IOException {
// create a copy of the geocode list, as we need to modify it, but it might be immutable
final List<String> allGeocodes = new ArrayList<>(allGeocodesIn);
this.progressListener = progressListener;
gpx.setOutput(writer);
gpx.startDocument(CharEncoding.UTF_8, true);
gpx.setPrefix(PREFIX_GPX, NS_GPX);
gpx.setPrefix(PREFIX_XSI, NS_XSI);
gpx.setPrefix(PREFIX_GROUNDSPEAK, NS_GROUNDSPEAK);
gpx.setPrefix(PREFIX_GSAK, NS_GSAK);
gpx.setPrefix(PREFIX_CGEO, NS_CGEO);
gpx.startTag(NS_GPX, "gpx");
gpx.attribute("", "version", "1.0");
gpx.attribute("", "creator", "c:geo - http://www.cgeo.org/");
gpx.attribute(NS_XSI, "schemaLocation", NS_GPX + " " + GPX_SCHEMA + " " + NS_GROUNDSPEAK + " " + GROUNDSPEAK_SCHEMA + " " + NS_GSAK + " " + GSAK_SCHEMA);
// Split the overall set of geocodes into small chunks. That is a compromise between memory efficiency (because
// we don't load all caches fully into memory) and speed (because we don't query each cache separately).
while (!allGeocodes.isEmpty()) {
final List<String> batch = allGeocodes.subList(0, Math.min(CACHES_PER_BATCH, allGeocodes.size()));
exportBatch(gpx, batch);
batch.clear();
}
gpx.endTag(NS_GPX, "gpx");
gpx.endDocument();
}
private void exportBatch(final XmlSerializer gpx, @NonNull final Collection<String> geocodesOfBatch) throws IOException {
final Set<Geocache> caches = DataStore.loadCaches(geocodesOfBatch, LoadFlags.LOAD_ALL_DB_ONLY);
for (final Geocache cache : caches) {
if (cache == null) {
continue;
}
final Geopoint coords = cache.getCoords();
if (coords == null) {
// Export would be invalid without coordinates.
continue;
}
gpx.startTag(NS_GPX, "wpt");
gpx.attribute("", "lat", Double.toString(coords.getLatitude()));
gpx.attribute("", "lon", Double.toString(coords.getLongitude()));
final Date hiddenDate = cache.getHiddenDate();
if (hiddenDate != null) {
XmlUtils.simpleText(gpx, NS_GPX, "time", dateFormatZ.format(hiddenDate));
}
XmlUtils.multipleTexts(gpx, NS_GPX, "name", cache.getGeocode(), "desc", cache.getName(), "url", cache.getUrl(), "urlname", cache.getName(), "sym", cache.isFound() && Settings.getIncludeFoundStatus() ? "Geocache Found" : "Geocache", "type", "Geocache|" + cache.getType().pattern);
gpx.startTag(NS_GROUNDSPEAK, "cache");
gpx.attribute("", "id", cache.getCacheId());
gpx.attribute("", "available", !cache.isDisabled() ? "True" : "False");
gpx.attribute("", "archived", cache.isArchived() ? "True" : "False");
XmlUtils.multipleTexts(gpx, NS_GROUNDSPEAK, "name", cache.getName(), "placed_by", cache.getOwnerDisplayName(), "owner", cache.getOwnerUserId(), "type", cache.getType().pattern, "container", cache.getSize().id);
writeAttributes(cache);
XmlUtils.multipleTexts(gpx, NS_GROUNDSPEAK, "difficulty", integerIfPossible(cache.getDifficulty()), "terrain", integerIfPossible(cache.getTerrain()), "country", getCountry(cache), "state", getState(cache));
gpx.startTag(NS_GROUNDSPEAK, "short_description");
gpx.attribute("", "html", TextUtils.containsHtml(cache.getShortDescription()) ? "True" : "False");
gpx.text(cache.getShortDescription());
gpx.endTag(NS_GROUNDSPEAK, "short_description");
gpx.startTag(NS_GROUNDSPEAK, "long_description");
gpx.attribute("", "html", TextUtils.containsHtml(cache.getDescription()) ? "True" : "False");
gpx.text(cache.getDescription());
gpx.endTag(NS_GROUNDSPEAK, "long_description");
XmlUtils.simpleText(gpx, NS_GROUNDSPEAK, "encoded_hints", cache.getHint());
writeLogs(cache);
writeTravelBugs(cache);
gpx.endTag(NS_GROUNDSPEAK, "cache");
writeGsakExtensions(cache);
gpx.endTag(NS_GPX, "wpt");
writeWaypoints(cache);
countExported++;
if (progressListener != null) {
progressListener.publishProgress(countExported);
}
}
}
private void writeGsakExtensions(@NonNull final Geocache cache) throws IOException {
gpx.startTag(NS_GSAK, "wptExtension");
XmlUtils.multipleTexts(gpx, NS_GSAK, "Watch", gpxBoolean(cache.isOnWatchlist()), "IsPremium", gpxBoolean(cache.isPremiumMembersOnly()), "FavPoints", Integer.toString(cache.getFavoritePoints()), "GcNote", StringUtils.trimToEmpty(cache.getPersonalNote()));
gpx.endTag(NS_GSAK, "wptExtension");
}
/**
* @return XML schema compliant boolean representation of the boolean flag. This must be either true, false, 0 or 1,
* but no other value (also not upper case True/False).
*/
private static String gpxBoolean(final boolean boolFlag) {
return boolFlag ? "true" : "false";
}
private void writeWaypoints(@NonNull final Geocache cache) throws IOException {
final List<Waypoint> waypoints = cache.getWaypoints();
final List<Waypoint> ownWaypoints = new ArrayList<>(waypoints.size());
final List<Waypoint> originWaypoints = new ArrayList<>(waypoints.size());
int maxPrefix = 0;
for (final Waypoint wp : cache.getWaypoints()) {
// Retrieve numerical prefixes to have a basis for assigning prefixes to own waypoints
final String prefix = wp.getPrefix();
if (StringUtils.isNotBlank(prefix)) {
try {
final int numericPrefix = Integer.parseInt(prefix);
maxPrefix = Math.max(numericPrefix, maxPrefix);
} catch (final NumberFormatException ignored) {
// ignore non numeric prefix, as it should be unique in the list of non-own waypoints already
}
}
if (wp.isUserDefined()) {
ownWaypoints.add(wp);
} else {
originWaypoints.add(wp);
}
}
for (final Waypoint wp : originWaypoints) {
writeCacheWaypoint(wp);
}
// Prefixes must be unique. There use numeric strings as prefixes in OWN waypoints where they are missing
for (final Waypoint wp : ownWaypoints) {
if (StringUtils.isBlank(wp.getPrefix()) || StringUtils.equalsIgnoreCase(Waypoint.PREFIX_OWN, wp.getPrefix())) {
maxPrefix++;
wp.setPrefix(StringUtils.leftPad(String.valueOf(maxPrefix), 2, '0'));
}
writeCacheWaypoint(wp);
}
}
/**
* Writes one waypoint entry for cache waypoint.
*/
private void writeCacheWaypoint(@NonNull final Waypoint wp) throws IOException {
final Geopoint coords = wp.getCoords();
// TODO: create some extension to GPX to include waypoint without coords
if (coords != null) {
gpx.startTag(NS_GPX, "wpt");
gpx.attribute("", "lat", Double.toString(coords.getLatitude()));
gpx.attribute("", "lon", Double.toString(coords.getLongitude()));
final String waypointTypeGpx = wp.getWaypointType().gpx;
XmlUtils.multipleTexts(gpx, NS_GPX, "name", wp.getGpxId(), "cmt", wp.getNote(), "desc", wp.getName(), "sym", waypointTypeGpx, "type", "Waypoint|" + waypointTypeGpx);
// add parent reference the GSAK-way
gpx.startTag(NS_GSAK, "wptExtension");
gpx.startTag(NS_GSAK, "Parent");
gpx.text(wp.getGeocode());
gpx.endTag(NS_GSAK, "Parent");
gpx.endTag(NS_GSAK, "wptExtension");
if (wp.isVisited()) {
gpx.startTag(NS_CGEO, "visited");
gpx.text("true");
gpx.endTag(NS_CGEO, "visited");
}
if (wp.isUserDefined()) {
gpx.startTag(NS_CGEO, "userdefined");
gpx.text("true");
gpx.endTag(NS_CGEO, "userdefined");
}
gpx.endTag(NS_GPX, "wpt");
}
}
private void writeLogs(@NonNull final Geocache cache) throws IOException {
final List<LogEntry> logs = cache.getLogs();
if (logs.isEmpty()) {
return;
}
gpx.startTag(NS_GROUNDSPEAK, "logs");
for (final LogEntry log : logs) {
gpx.startTag(NS_GROUNDSPEAK, "log");
gpx.attribute("", "id", Integer.toString(log.id));
XmlUtils.multipleTexts(gpx, NS_GROUNDSPEAK, "date", dateFormatZ.format(new Date(log.date)), "type", log.getType().type);
gpx.startTag(NS_GROUNDSPEAK, "finder");
gpx.attribute("", "id", "");
gpx.text(log.author);
gpx.endTag(NS_GROUNDSPEAK, "finder");
gpx.startTag(NS_GROUNDSPEAK, "text");
gpx.attribute("", "encoded", "False");
try {
gpx.text(log.log);
} catch (final IllegalArgumentException e) {
Log.e("GpxSerializer.writeLogs: cannot write log " + log.id + " for cache " + cache.getGeocode(), e);
gpx.text(" [end of log omitted due to an invalid character]");
}
gpx.endTag(NS_GROUNDSPEAK, "text");
gpx.endTag(NS_GROUNDSPEAK, "log");
}
gpx.endTag(NS_GROUNDSPEAK, "logs");
}
private void writeTravelBugs(@NonNull final Geocache cache) throws IOException {
final List<Trackable> inventory = cache.getInventory();
if (CollectionUtils.isEmpty(inventory)) {
return;
}
gpx.startTag(NS_GROUNDSPEAK, "travelbugs");
for (final Trackable trackable : inventory) {
gpx.startTag(NS_GROUNDSPEAK, "travelbug");
// in most cases the geocode will be empty (only the guid is known). those travel bugs cannot be imported again!
gpx.attribute("", "ref", trackable.getGeocode());
XmlUtils.simpleText(gpx, NS_GROUNDSPEAK, "name", trackable.getName());
gpx.endTag(NS_GROUNDSPEAK, "travelbug");
}
gpx.endTag(NS_GROUNDSPEAK, "travelbugs");
}
private void writeAttributes(@NonNull final Geocache cache) throws IOException {
if (cache.getAttributes().isEmpty()) {
return;
}
//TODO: Attribute conversion required: English verbose name, gpx-id
gpx.startTag(NS_GROUNDSPEAK, "attributes");
for (final String attribute : cache.getAttributes()) {
final CacheAttribute attr = CacheAttribute.getByRawName(CacheAttribute.trimAttributeName(attribute));
if (attr == null) {
continue;
}
final boolean enabled = CacheAttribute.isEnabled(attribute);
gpx.startTag(NS_GROUNDSPEAK, "attribute");
gpx.attribute("", "id", Integer.toString(attr.gcid));
gpx.attribute("", "inc", enabled ? "1" : "0");
gpx.text(attr.getL10n(enabled));
gpx.endTag(NS_GROUNDSPEAK, "attribute");
}
gpx.endTag(NS_GROUNDSPEAK, "attributes");
}
static String getState(@NonNull final Geocache cache) {
return getLocationPart(cache, 0);
}
private static String getLocationPart(@NonNull final Geocache cache, final int partIndex) {
final String location = cache.getLocation();
if (StringUtils.contains(location, ", ")) {
final String[] parts = StringUtils.split(location, ',');
if (parts.length == 2) {
return StringUtils.trim(parts[partIndex]);
}
}
return StringUtils.EMPTY;
}
static String getCountry(@NonNull final Geocache cache) {
final String country = getLocationPart(cache, 1);
if (StringUtils.isNotEmpty(country)) {
return country;
}
// fall back to returning everything, but only for the country
return cache.getLocation();
}
private static String integerIfPossible(final double value) {
if (value == (long) value) {
return String.format(Locale.ENGLISH, "%d", (long) value);
}
return String.format(Locale.ENGLISH, "%s", value);
}
}