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