package cgeo.geocaching.connector.trackable;
import cgeo.geocaching.CgeoApplication;
import cgeo.geocaching.R;
import cgeo.geocaching.log.LogEntry;
import cgeo.geocaching.log.LogType;
import cgeo.geocaching.models.Image;
import cgeo.geocaching.models.Trackable;
import cgeo.geocaching.utils.Log;
import cgeo.geocaching.utils.SynchronizedDateFormat;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.io.IOException;
import java.io.StringReader;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.DefaultHandler;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
class GeokretyParser {
private GeokretyParser() {
// Utility class
}
private static class GeokretyHandler extends DefaultHandler {
private static final SynchronizedDateFormat DATE_FORMAT = new SynchronizedDateFormat("yyyy-MM-dd kk:mm", TimeZone.getTimeZone("UTC"), Locale.US);
private static final SynchronizedDateFormat DATE_FORMAT_SECONDS = new SynchronizedDateFormat("yyyy-MM-dd kk:mm:ss", TimeZone.getTimeZone("UTC"), Locale.US);
private final List<Trackable> trackables = new ArrayList<>();
private Trackable trackable;
private LogEntry.Builder logEntryBuilder;
private final List<LogEntry> logsEntries = new ArrayList<>();
private Image.Builder imageBuilder;
private boolean isMultiline = false;
private boolean isInMoves = false;
private boolean isInImages = false;
private boolean isInComments = false;
private String content;
@NonNull
public final List<Trackable> getTrackables() {
return trackables;
}
@Override
public final void startElement(final String uri, final String localName, final String qName,
final Attributes attributes) throws SAXException {
content = "";
if (localName.equalsIgnoreCase("geokret")) {
trackable = new Trackable();
trackable.forceSetBrand(TrackableBrand.GEOKRETY);
trackables.add(trackable);
trackable.setSpottedType(Trackable.SPOTTED_OWNER);
}
try {
if (localName.equalsIgnoreCase("geokret")) {
final String kretyId = attributes.getValue("id");
if (StringUtils.isNumeric(kretyId)) {
trackable.setGeocode(GeokretyConnector.geocode(Integer.parseInt(kretyId)));
}
final String distance = attributes.getValue("dist");
if (StringUtils.isNotBlank(distance)) {
trackable.setDistance(Float.parseFloat(distance));
}
final String trackingcode = attributes.getValue("nr");
if (StringUtils.isNotBlank(trackingcode)) {
trackable.setTrackingcode(trackingcode);
}
final String kretyType = attributes.getValue("type");
if (StringUtils.isNotBlank(kretyType)) {
trackable.setType(getType(Integer.parseInt(kretyType)));
}
final String kretyState = attributes.getValue("state");
if (StringUtils.isNotBlank(kretyState)) {
trackable.setSpottedType(getSpottedType(Integer.parseInt(kretyState)));
}
final String waypointCode = attributes.getValue("waypoint");
if (StringUtils.isNotBlank(waypointCode)) {
trackable.setSpottedName(waypointCode);
}
final String imageName = attributes.getValue("image");
if (StringUtils.isNotBlank(imageName)) {
trackable.setImage("http://geokrety.org/obrazki/" + imageName);
}
final String ownerId = attributes.getValue("owner_id");
if (StringUtils.isNotBlank(ownerId)) {
trackable.setOwner(CgeoApplication.getInstance().getString(R.string.init_geokrety_userid, ownerId));
}
final String missing = attributes.getValue("missing");
if (StringUtils.isNotBlank(missing)) {
trackable.setMissing("1".equalsIgnoreCase(missing));
}
}
if (localName.equalsIgnoreCase("owner")) {
final String ownerId = attributes.getValue("id");
if (StringUtils.isNotBlank(ownerId)) {
trackable.setOwner(CgeoApplication.getInstance().getString(R.string.init_geokrety_userid, ownerId));
}
}
if (localName.equalsIgnoreCase("type")) {
final String kretyType = attributes.getValue("id");
if (StringUtils.isNotBlank(kretyType)) {
trackable.setType(getType(Integer.parseInt(kretyType)));
}
}
if (localName.equalsIgnoreCase("description")) {
isMultiline = true;
}
// TODO: latitude/longitude could be parsed, but trackable doesn't support it, yet...
//if (localName.equalsIgnoreCase("position")) {
//final String latitude = attributes.getValue("latitude");
//if (StringUtils.isNotBlank(latitude) {
// trackable.setLatitude(latitude);
//}
//final String longitude = attributes.getValue("longitude");
//if (StringUtils.isNotBlank(longitude) {
// trackable.setLongitude(longitude);
//}
//}
if (localName.equalsIgnoreCase("move")) {
logEntryBuilder = new LogEntry.Builder();
isInMoves = true;
}
if (localName.equalsIgnoreCase("date")) {
final String movedDate = attributes.getValue("moved");
if (StringUtils.isNotBlank(movedDate)) {
logEntryBuilder.setDate(DATE_FORMAT.parse(movedDate).getTime());
}
}
if (localName.equalsIgnoreCase("user") && !isInComments) {
final String userId = attributes.getValue("id");
if (StringUtils.isNotBlank(userId)) {
logEntryBuilder.setAuthor(CgeoApplication.getInstance().getString(R.string.init_geokrety_userid, userId));
}
}
if (localName.equalsIgnoreCase("comments")) {
isInComments = true;
}
if (localName.equalsIgnoreCase("comment")) {
isMultiline = true;
}
if (localName.equalsIgnoreCase("logtype")) {
final String logtype = attributes.getValue("id");
logEntryBuilder.setLogType(getLogType(Integer.parseInt(logtype)));
}
if (localName.equalsIgnoreCase("images")) {
isInImages = true;
}
if (localName.equalsIgnoreCase("image")) {
imageBuilder = new Image.Builder();
final String title = attributes.getValue("title");
if (StringUtils.isNotBlank(title)) {
imageBuilder.setTitle(title);
}
}
} catch (final ParseException | NumberFormatException e) {
Log.e("Parsing GeoKret", e);
}
}
@Override
public final void endElement(final String uri, final String localName, final String qName)
throws SAXException {
try {
if (localName.equalsIgnoreCase("geokret")) {
if (StringUtils.isNotEmpty(content) && StringUtils.isBlank(trackable.getName())) {
trackable.setName(content);
}
// This is a special case. Deal it at the end of the "geokret" parsing (xml close)
if (trackable.getSpottedType() == Trackable.SPOTTED_USER) {
if (trackable.getDistance() == 0) {
trackable.setSpottedType(Trackable.SPOTTED_OWNER);
trackable.setSpottedName(trackable.getOwner());
} else {
trackable.setSpottedName(getLastSpottedUsername(logsEntries));
}
}
trackable.setLogs(logsEntries);
}
if (localName.equalsIgnoreCase("name")) {
trackable.setName(content);
}
if (localName.equalsIgnoreCase("description")) {
trackable.setDetails(content);
isMultiline = false;
}
if (localName.equalsIgnoreCase("owner")) {
trackable.setOwner(content);
}
if (StringUtils.isNotBlank(content) && localName.equalsIgnoreCase("datecreated")) {
final Date date = DATE_FORMAT_SECONDS.parse(content);
trackable.setReleased(date);
}
if (StringUtils.isNotBlank(content) && !isInMoves && (
localName.equalsIgnoreCase("distancetravelled") || localName.equalsIgnoreCase("distancetraveled")
)) {
trackable.setDistance(Float.parseFloat(content));
}
if (localName.equalsIgnoreCase("images")) {
isInImages = false;
}
if (StringUtils.isNotBlank(content) && localName.equalsIgnoreCase("image")) {
if (isInMoves) {
imageBuilder.setUrl("http://geokrety.org/obrazki/" + content);
logEntryBuilder.addLogImage(imageBuilder.build());
} else if (!isInImages) {
// TODO: Trackable doesn't support multiple image yet, so ignore other image tags if we're not in moves
trackable.setImage("http://geokrety.org/obrazki/" + content);
}
}
if (StringUtils.isNotBlank(content) && localName.equalsIgnoreCase("state")) {
trackable.setSpottedType(getSpottedType(Integer.parseInt(content)));
}
if (StringUtils.isNotBlank(content) && localName.equalsIgnoreCase("missing")) {
trackable.setMissing("1".equalsIgnoreCase(content));
}
if (StringUtils.isNotBlank(content) && localName.equalsIgnoreCase("waypoint")) {
trackable.setSpottedName(content);
}
if (StringUtils.isNotBlank(content) && localName.equalsIgnoreCase("user") && !isInComments) {
logEntryBuilder.setAuthor(content);
}
if (localName.equalsIgnoreCase("move")) {
isInMoves = false;
logsEntries.add(logEntryBuilder.build());
}
if (localName.equalsIgnoreCase("comments")) {
isInComments = false;
}
if (localName.equalsIgnoreCase("comment") && !isInComments) {
isMultiline = false;
logEntryBuilder.setLog(content);
}
if (StringUtils.isNotBlank(content) && localName.equalsIgnoreCase("wpt")) {
logEntryBuilder.setCacheGeocode(content);
logEntryBuilder.setCacheName(content);
}
if (localName.equalsIgnoreCase("id")) {
logEntryBuilder.setId(Integer.parseInt(content));
}
} catch (final ParseException | NumberFormatException e) {
Log.e("Parsing GeoKret", e);
}
}
@Override
public final void characters(final char[] ch, final int start, final int length)
throws SAXException {
final String text = new String(ch, start, length);
if (isMultiline) {
content = StringUtils.join(content, text.replaceAll("(\r\n|\n)", "<br />"));
} else {
content = StringUtils.trim(text);
}
}
/**
* Convert states from GK to c:geo spotted types. See: http://geokrety.org/api.php
*
* @param state
* the GK state read from xml
* @return
* The spotted types as defined in Trackables
*/
private static int getSpottedType(final int state) {
switch (state) {
case 0: // Dropped
case 3: // Seen in
return Trackable.SPOTTED_CACHE;
case 1: // Grabbed from
case 5: // Visiting
return Trackable.SPOTTED_USER;
case 4: // Archived
return Trackable.SPOTTED_ARCHIVED;
//case 2: // A comment (however this case doesn't exists in db)
}
return Trackable.SPOTTED_UNKNOWN;
}
/**
* Convert states from GK to c:geo spotted types.
*
* @param type
* the GK Log type
* @return
* The LogType
*/
private static LogType getLogType(final int type) {
switch (type) {
case 0: // Dropped
return LogType.PLACED_IT;
case 1: // Grabbed from
return LogType.GRABBED_IT;
case 2: // A comment
return LogType.NOTE;
case 3: // Seen in
return LogType.DISCOVERED_IT;
case 4: // Archived
return LogType.ARCHIVE;
case 5: // Visiting
return LogType.VISIT;
}
return LogType.UNKNOWN;
}
}
@NonNull
public static List<Trackable> parse(final InputSource page) {
if (page != null) {
try {
// Create a new instance of the SAX parser
final SAXParserFactory saxPF = SAXParserFactory.newInstance();
final SAXParser saxP = saxPF.newSAXParser();
final XMLReader xmlR = saxP.getXMLReader();
// Create the Handler to handle each of the XML tags.
final GeokretyHandler gkXMLHandler = new GeokretyHandler();
xmlR.setContentHandler(gkXMLHandler);
xmlR.parse(page);
return gkXMLHandler.getTrackables();
} catch (final SAXException | IOException | ParserConfigurationException e) {
Log.w("Cannot parse GeoKrety", e);
}
}
return Collections.emptyList();
}
private static class GeokretyRuchyXmlParser {
private int gkid;
private final List<String> errors;
private String text;
GeokretyRuchyXmlParser() {
errors = new ArrayList<>();
gkid = 0;
}
public List<String> getErrors() {
return errors;
}
int getGkid() {
return gkid;
}
@NonNull
public List<String> parse(final String page) {
try {
final XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
factory.setNamespaceAware(true);
final XmlPullParser parser = factory.newPullParser();
parser.setInput(new StringReader(page));
int eventType = parser.getEventType();
while (eventType != XmlPullParser.END_DOCUMENT) {
final String tagname = parser.getName();
switch (eventType) {
case XmlPullParser.START_TAG:
if (tagname.equalsIgnoreCase("geokret")) {
gkid = Integer.parseInt(parser.getAttributeValue(null, "id"));
}
break;
case XmlPullParser.TEXT:
text = parser.getText();
break;
case XmlPullParser.END_TAG:
if (tagname.equalsIgnoreCase("error") && text != null && !text.trim().isEmpty()) {
errors.add(text);
}
break;
default:
break;
}
eventType = parser.next();
}
} catch (XmlPullParserException | IOException e) {
Log.e("GeokretyRuchyXmlParser: Error Parsing geokret", e);
errors.add(CgeoApplication.getInstance().getString(R.string.geokrety_parsing_failed));
}
return errors;
}
}
@Nullable
protected static String getType(final int type) {
switch (type) {
case 0:
return CgeoApplication.getInstance().getString(R.string.geokret_type_traditional);
case 1:
return CgeoApplication.getInstance().getString(R.string.geokret_type_book_or_media);
case 2:
return CgeoApplication.getInstance().getString(R.string.geokret_type_human);
case 3:
return CgeoApplication.getInstance().getString(R.string.geokret_type_coin);
case 4:
return CgeoApplication.getInstance().getString(R.string.geokret_type_post);
}
return null;
}
@Nullable
static ImmutablePair<Integer, List<String>> parseResponse(final String page) {
if (page != null) {
try {
final GeokretyRuchyXmlParser parser = new GeokretyRuchyXmlParser();
parser.parse(page);
return new ImmutablePair<>(parser.getGkid(), parser.getErrors());
} catch (final Exception e) {
Log.w("Cannot parse response for the GeoKret", e);
}
}
return null;
}
/**
* Determine from the newest logs (ignoring Notes) if the GK is spotted
* in the hand of someone.
*
* @param logsEntries
* the log entries to analyze
* @return
* The spotted username (or unknown)
*/
static String getLastSpottedUsername(final List<LogEntry> logsEntries) {
for (final LogEntry log: logsEntries) {
final LogType logType = log.getType();
if (logType == LogType.GRABBED_IT || logType == LogType.VISIT) {
return log.author;
}
if (logType != LogType.NOTE) {
break;
}
}
return CgeoApplication.getInstance().getString(R.string.user_unknown);
}
}