001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io;
003
004import java.io.ByteArrayInputStream;
005import java.io.IOException;
006import java.io.InputStream;
007import java.nio.charset.StandardCharsets;
008import java.util.ArrayList;
009import java.util.Date;
010import java.util.List;
011import java.util.Locale;
012import java.util.Optional;
013import java.util.function.UnaryOperator;
014
015import javax.xml.parsers.ParserConfigurationException;
016
017import org.openstreetmap.josm.data.coor.LatLon;
018import org.openstreetmap.josm.data.notes.Note;
019import org.openstreetmap.josm.data.notes.NoteComment;
020import org.openstreetmap.josm.data.notes.NoteComment.Action;
021import org.openstreetmap.josm.data.osm.User;
022import org.openstreetmap.josm.tools.Logging;
023import org.openstreetmap.josm.tools.XmlUtils;
024import org.openstreetmap.josm.tools.date.DateUtils;
025import org.xml.sax.Attributes;
026import org.xml.sax.InputSource;
027import org.xml.sax.SAXException;
028import org.xml.sax.helpers.DefaultHandler;
029
030/**
031 * Class to read Note objects from their XML representation. It can take
032 * either API style XML which starts with an "osm" tag or a planet dump
033 * style XML which starts with an "osm-notes" tag.
034 */
035public class NoteReader {
036
037    private final InputSource inputSource;
038    private List<Note> parsedNotes;
039
040    /**
041     * Notes can be represented in two XML formats. One is returned by the API
042     * while the other is used to generate the notes dump file. The parser
043     * needs to know which one it is handling.
044     */
045    private enum NoteParseMode {
046        API,
047        DUMP
048    }
049
050    /**
051     * SAX handler to read note information from its XML representation.
052     * Reads both API style and planet dump style formats.
053     */
054    private class Parser extends DefaultHandler {
055
056        private NoteParseMode parseMode;
057        private final StringBuilder buffer = new StringBuilder();
058        private Note thisNote;
059        private long commentUid;
060        private String commentUsername;
061        private Action noteAction;
062        private Date commentCreateDate;
063        private boolean commentIsNew;
064        private List<Note> notes;
065        private String commentText;
066
067        @Override
068        public void characters(char[] ch, int start, int length) throws SAXException {
069            buffer.append(ch, start, length);
070        }
071
072        @Override
073        public void startElement(String uri, String localName, String qName, Attributes attrs) throws SAXException {
074            buffer.setLength(0);
075            switch(qName) {
076            case "osm":
077                parseMode = NoteParseMode.API;
078                notes = new ArrayList<>(100);
079                return;
080            case "osm-notes":
081                parseMode = NoteParseMode.DUMP;
082                notes = new ArrayList<>(10_000);
083                return;
084            }
085
086            if (parseMode == NoteParseMode.API) {
087                if ("note".equals(qName)) {
088                    thisNote = parseNoteBasic(attrs);
089                }
090                return;
091            }
092
093            //The rest only applies for dump mode
094            switch(qName) {
095            case "note":
096                thisNote = parseNoteFull(attrs);
097                break;
098            case "comment":
099                commentUid = Long.parseLong(Optional.ofNullable(attrs.getValue("uid")).orElse("0"));
100                commentUsername = attrs.getValue("user");
101                noteAction = Action.valueOf(attrs.getValue("action").toUpperCase(Locale.ENGLISH));
102                commentCreateDate = DateUtils.fromString(attrs.getValue("timestamp"));
103                commentIsNew = Boolean.parseBoolean(Optional.ofNullable(attrs.getValue("is_new")).orElse("false"));
104                break;
105            default: // Do nothing
106            }
107        }
108
109        @Override
110        public void endElement(String namespaceURI, String localName, String qName) {
111            if (notes != null && "note".equals(qName)) {
112                notes.add(thisNote);
113            }
114            if ("comment".equals(qName)) {
115                User commentUser = User.createOsmUser(commentUid, commentUsername);
116                if (commentUid == 0) {
117                    commentUser = User.getAnonymous();
118                }
119                if (parseMode == NoteParseMode.API) {
120                    commentIsNew = false;
121                }
122                if (parseMode == NoteParseMode.DUMP) {
123                    commentText = buffer.toString();
124                }
125                thisNote.addComment(new NoteComment(commentCreateDate, commentUser, commentText, noteAction, commentIsNew));
126                commentUid = 0;
127                commentUsername = null;
128                commentCreateDate = null;
129                commentIsNew = false;
130                commentText = null;
131            }
132            if (parseMode == NoteParseMode.DUMP) {
133                return;
134            }
135
136            //the rest only applies to API mode
137            switch (qName) {
138            case "id":
139                thisNote.setId(Long.parseLong(buffer.toString()));
140                break;
141            case "status":
142                thisNote.setState(Note.State.valueOf(buffer.toString().toUpperCase(Locale.ENGLISH)));
143                break;
144            case "date_created":
145                thisNote.setCreatedAt(DateUtils.fromString(buffer.toString()));
146                break;
147            case "date_closed":
148                thisNote.setClosedAt(DateUtils.fromString(buffer.toString()));
149                break;
150            case "date":
151                commentCreateDate = DateUtils.fromString(buffer.toString());
152                break;
153            case "user":
154                commentUsername = buffer.toString();
155                break;
156            case "uid":
157                commentUid = Long.parseLong(buffer.toString());
158                break;
159            case "text":
160                commentText = buffer.toString();
161                buffer.setLength(0);
162                break;
163            case "action":
164                noteAction = Action.valueOf(buffer.toString().toUpperCase(Locale.ENGLISH));
165                break;
166            case "note": //nothing to do for comment or note, already handled above
167            case "comment":
168                break;
169            }
170        }
171
172        @Override
173        public void endDocument() throws SAXException {
174            parsedNotes = notes;
175        }
176    }
177
178    static LatLon parseLatLon(UnaryOperator<String> attrs) {
179        double lat = Double.parseDouble(attrs.apply("lat"));
180        double lon = Double.parseDouble(attrs.apply("lon"));
181        return new LatLon(lat, lon);
182    }
183
184    static Note parseNoteBasic(Attributes attrs) {
185        return parseNoteBasic(attrs::getValue);
186    }
187
188    static Note parseNoteBasic(UnaryOperator<String> attrs) {
189        return new Note(parseLatLon(attrs));
190    }
191
192    static Note parseNoteFull(Attributes attrs) {
193        return parseNoteFull(attrs::getValue);
194    }
195
196    static Note parseNoteFull(UnaryOperator<String> attrs) {
197        Note note = parseNoteBasic(attrs);
198        String id = attrs.apply("id");
199        if (id != null) {
200            note.setId(Long.parseLong(id));
201        }
202        String closedTimeStr = attrs.apply("closed_at");
203        if (closedTimeStr == null) { //no closed_at means the note is still open
204            note.setState(Note.State.OPEN);
205        } else {
206            note.setState(Note.State.CLOSED);
207            note.setClosedAt(DateUtils.fromString(closedTimeStr));
208        }
209        String createdAt = attrs.apply("created_at");
210        if (createdAt != null) {
211            note.setCreatedAt(DateUtils.fromString(createdAt));
212        }
213        return note;
214    }
215
216    /**
217     * Initializes the reader with a given InputStream
218     * @param source - InputStream containing Notes XML
219     */
220    public NoteReader(InputStream source) {
221        this.inputSource = new InputSource(source);
222    }
223
224    /**
225     * Initializes the reader with a string as a source
226     * @param source UTF-8 string containing Notes XML to parse
227     */
228    public NoteReader(String source) {
229        this.inputSource = new InputSource(new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8)));
230    }
231
232    /**
233     * Parses the InputStream given to the constructor and returns
234     * the resulting Note objects
235     * @return List of Notes parsed from the input data
236     * @throws SAXException if any SAX parsing error occurs
237     * @throws IOException if any I/O error occurs
238     */
239    public List<Note> parse() throws SAXException, IOException {
240        DefaultHandler parser = new Parser();
241        try {
242            XmlUtils.parseSafeSAX(inputSource, parser);
243        } catch (ParserConfigurationException e) {
244            Logging.error(e); // broken SAXException chaining
245            throw new SAXException(e);
246        }
247        return parsedNotes;
248    }
249}