001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.preferences;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.io.BufferedReader;
007import java.io.File;
008import java.io.IOException;
009import java.io.InputStream;
010import java.io.Reader;
011import java.nio.charset.StandardCharsets;
012import java.nio.file.Files;
013import java.util.ArrayList;
014import java.util.Collections;
015import java.util.LinkedHashMap;
016import java.util.List;
017import java.util.Map;
018import java.util.Optional;
019import java.util.SortedMap;
020import java.util.TreeMap;
021
022import javax.xml.XMLConstants;
023import javax.xml.stream.XMLStreamConstants;
024import javax.xml.stream.XMLStreamException;
025import javax.xml.stream.XMLStreamReader;
026import javax.xml.transform.stream.StreamSource;
027import javax.xml.validation.Schema;
028
029import org.openstreetmap.josm.io.CachedFile;
030import org.openstreetmap.josm.io.XmlStreamParsingException;
031import org.openstreetmap.josm.spi.preferences.ListListSetting;
032import org.openstreetmap.josm.spi.preferences.ListSetting;
033import org.openstreetmap.josm.spi.preferences.MapListSetting;
034import org.openstreetmap.josm.spi.preferences.Setting;
035import org.openstreetmap.josm.spi.preferences.StringSetting;
036import org.openstreetmap.josm.tools.Logging;
037import org.openstreetmap.josm.tools.XmlUtils;
038import org.xml.sax.SAXException;
039
040/**
041 * Loads preferences from XML.
042 */
043public class PreferencesReader {
044
045    private final SortedMap<String, Setting<?>> settings = new TreeMap<>();
046    private XMLStreamReader parser;
047    private int version;
048    private final Reader reader;
049    private final File file;
050
051    private final boolean defaults;
052
053    /**
054     * Constructs a new {@code PreferencesReader}.
055     * @param file the file
056     * @param defaults true when reading from the cache file for default preferences,
057     * false for the regular preferences config file
058     */
059    public PreferencesReader(File file, boolean defaults) {
060        this.defaults = defaults;
061        this.reader = null;
062        this.file = file;
063    }
064
065    /**
066     * Constructs a new {@code PreferencesReader}.
067     * @param reader the {@link Reader}
068     * @param defaults true when reading from the cache file for default preferences,
069     * false for the regular preferences config file
070     */
071    public PreferencesReader(Reader reader, boolean defaults) {
072        this.defaults = defaults;
073        this.reader = reader;
074        this.file = null;
075    }
076
077    /**
078     * Validate the XML.
079     * @param f the file
080     * @throws IOException if any I/O error occurs
081     * @throws SAXException if any SAX error occurs
082     */
083    public static void validateXML(File f) throws IOException, SAXException {
084        try (BufferedReader in = Files.newBufferedReader(f.toPath(), StandardCharsets.UTF_8)) {
085            validateXML(in);
086        }
087    }
088
089    /**
090     * Validate the XML.
091     * @param in the {@link Reader}
092     * @throws IOException if any I/O error occurs
093     * @throws SAXException if any SAX error occurs
094     */
095    public static void validateXML(Reader in) throws IOException, SAXException {
096        try (CachedFile cf = new CachedFile("resource://data/preferences.xsd"); InputStream xsdStream = cf.getInputStream()) {
097            Schema schema = XmlUtils.newXmlSchemaFactory().newSchema(new StreamSource(xsdStream));
098            XmlUtils.newSafeValidator(schema).validate(new StreamSource(in));
099        }
100    }
101
102    /**
103     * Return the parsed preferences as a settings map
104     * @return the parsed preferences as a settings map
105     */
106    public SortedMap<String, Setting<?>> getSettings() {
107        return settings;
108    }
109
110    /**
111     * Return the version from the XML root element.
112     * (Represents the JOSM version when the file was written.)
113     * @return the version
114     */
115    public int getVersion() {
116        return version;
117    }
118
119    /**
120     * Parse preferences.
121     * @throws XMLStreamException if any XML parsing error occurs
122     * @throws IOException if any I/O error occurs
123     */
124    public void parse() throws XMLStreamException, IOException {
125        if (reader != null) {
126            this.parser = XmlUtils.newSafeXMLInputFactory().createXMLStreamReader(reader);
127            doParse();
128        } else {
129            try (BufferedReader in = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)) {
130                this.parser = XmlUtils.newSafeXMLInputFactory().createXMLStreamReader(in);
131                doParse();
132            }
133        }
134    }
135
136    private void doParse() throws XMLStreamException {
137        int event = parser.getEventType();
138        while (true) {
139            if (event == XMLStreamConstants.START_ELEMENT) {
140                String topLevelElementName = defaults ? "preferences-defaults" : "preferences";
141                String localName = parser.getLocalName();
142                if (!topLevelElementName.equals(localName)) {
143                    throw new XMLStreamException(
144                            tr("Expected element ''{0}'', but got ''{1}''", topLevelElementName, localName),
145                            parser.getLocation());
146                }
147                try {
148                    version = Integer.parseInt(parser.getAttributeValue(null, "version"));
149                } catch (NumberFormatException e) {
150                    Logging.log(Logging.LEVEL_DEBUG, e);
151                }
152                parseRoot();
153            } else if (event == XMLStreamConstants.END_ELEMENT) {
154                return;
155            }
156            if (parser.hasNext()) {
157                event = parser.next();
158            } else {
159                break;
160            }
161        }
162        parser.close();
163    }
164
165    private void parseRoot() throws XMLStreamException {
166        while (true) {
167            int event = parser.next();
168            if (event == XMLStreamConstants.START_ELEMENT) {
169                String localName = parser.getLocalName();
170                switch(localName) {
171                case "tag":
172                    StringSetting setting;
173                    if (defaults && isNil()) {
174                        setting = new StringSetting(null);
175                    } else {
176                        setting = new StringSetting(Optional.ofNullable(parser.getAttributeValue(null, "value"))
177                                .orElseThrow(() -> new XMLStreamException(tr("value expected"), parser.getLocation())));
178                    }
179                    if (defaults) {
180                        setting.setTime(Math.round(Double.parseDouble(parser.getAttributeValue(null, "time"))));
181                    }
182                    settings.put(parser.getAttributeValue(null, "key"), setting);
183                    jumpToEnd();
184                    break;
185                case "list":
186                case "lists":
187                case "maps":
188                    parseToplevelList();
189                    break;
190                default:
191                    throwException("Unexpected element: "+localName);
192                }
193            } else if (event == XMLStreamConstants.END_ELEMENT) {
194                return;
195            }
196        }
197    }
198
199    private void jumpToEnd() throws XMLStreamException {
200        while (true) {
201            int event = parser.next();
202            if (event == XMLStreamConstants.START_ELEMENT) {
203                jumpToEnd();
204            } else if (event == XMLStreamConstants.END_ELEMENT) {
205                return;
206            }
207        }
208    }
209
210    private void parseToplevelList() throws XMLStreamException {
211        String key = parser.getAttributeValue(null, "key");
212        Long time = null;
213        if (defaults) {
214            time = Math.round(Double.parseDouble(parser.getAttributeValue(null, "time")));
215        }
216        String name = parser.getLocalName();
217
218        List<String> entries = null;
219        List<List<String>> lists = null;
220        List<Map<String, String>> maps = null;
221        if (defaults && isNil()) {
222            Setting<?> setting;
223            switch (name) {
224                case "lists":
225                    setting = new ListListSetting(null);
226                    break;
227                case "maps":
228                    setting = new MapListSetting(null);
229                    break;
230                default:
231                    setting = new ListSetting(null);
232                    break;
233            }
234            setting.setTime(time);
235            settings.put(key, setting);
236            jumpToEnd();
237        } else {
238            while (true) {
239                int event = parser.next();
240                if (event == XMLStreamConstants.START_ELEMENT) {
241                    String localName = parser.getLocalName();
242                    switch(localName) {
243                    case "entry":
244                        if (entries == null) {
245                            entries = new ArrayList<>();
246                        }
247                        entries.add(parser.getAttributeValue(null, "value"));
248                        jumpToEnd();
249                        break;
250                    case "list":
251                        if (lists == null) {
252                            lists = new ArrayList<>();
253                        }
254                        lists.add(parseInnerList());
255                        break;
256                    case "map":
257                        if (maps == null) {
258                            maps = new ArrayList<>();
259                        }
260                        maps.add(parseMap());
261                        break;
262                    default:
263                        throwException("Unexpected element: "+localName);
264                    }
265                } else if (event == XMLStreamConstants.END_ELEMENT) {
266                    break;
267                }
268            }
269            Setting<?> setting;
270            if (entries != null) {
271                setting = new ListSetting(Collections.unmodifiableList(entries));
272            } else if (lists != null) {
273                setting = new ListListSetting(Collections.unmodifiableList(lists));
274            } else if (maps != null) {
275                setting = new MapListSetting(Collections.unmodifiableList(maps));
276            } else {
277                switch (name) {
278                    case "lists":
279                        setting = new ListListSetting(Collections.<List<String>>emptyList());
280                        break;
281                    case "maps":
282                        setting = new MapListSetting(Collections.<Map<String, String>>emptyList());
283                        break;
284                    default:
285                        setting = new ListSetting(Collections.<String>emptyList());
286                        break;
287                }
288            }
289            if (defaults) {
290                setting.setTime(time);
291            }
292            settings.put(key, setting);
293        }
294    }
295
296    private List<String> parseInnerList() throws XMLStreamException {
297        List<String> entries = new ArrayList<>();
298        while (true) {
299            int event = parser.next();
300            if (event == XMLStreamConstants.START_ELEMENT) {
301                if ("entry".equals(parser.getLocalName())) {
302                    entries.add(parser.getAttributeValue(null, "value"));
303                    jumpToEnd();
304                } else {
305                    throwException("Unexpected element: "+parser.getLocalName());
306                }
307            } else if (event == XMLStreamConstants.END_ELEMENT) {
308                break;
309            }
310        }
311        return Collections.unmodifiableList(entries);
312    }
313
314    private Map<String, String> parseMap() throws XMLStreamException {
315        Map<String, String> map = new LinkedHashMap<>();
316        while (true) {
317            int event = parser.next();
318            if (event == XMLStreamConstants.START_ELEMENT) {
319                if ("tag".equals(parser.getLocalName())) {
320                    map.put(parser.getAttributeValue(null, "key"), parser.getAttributeValue(null, "value"));
321                    jumpToEnd();
322                } else {
323                    throwException("Unexpected element: "+parser.getLocalName());
324                }
325            } else if (event == XMLStreamConstants.END_ELEMENT) {
326                break;
327            }
328        }
329        return Collections.unmodifiableMap(map);
330    }
331
332    /**
333     * Check if the current element is nil (meaning the value of the setting is null).
334     * @return true, if the current element is nil
335     * @see <a href="https://msdn.microsoft.com/en-us/library/2b314yt2(v=vs.85).aspx">Nillable Attribute on MS Developer Network</a>
336     */
337    private boolean isNil() {
338        String nil = parser.getAttributeValue(XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI, "nil");
339        return "true".equals(nil) || "1".equals(nil);
340    }
341
342    /**
343     * Throw XmlStreamParsingException with line and column number.
344     *
345     * Only use this for errors that should not be possible after schema validation.
346     * @param msg the error message
347     * @throws XmlStreamParsingException always
348     */
349    private void throwException(String msg) throws XmlStreamParsingException {
350        throw new XmlStreamParsingException(msg, parser.getLocation());
351    }
352}