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.openstreetmap.josm.io.XmlStreamParsingException;
034import org.xml.sax.SAXException;
035
036/**
037 * Loads preferences from XML.
038 */
039public class PreferencesReader {
040
041    private final SortedMap<String, Setting<?>> settings = new TreeMap<>();
042    private XMLStreamReader parser;
043    private int version;
044    private final Reader reader;
045    private final File file;
046
047    private final boolean defaults;
048
049    /**
050     * Constructs a new {@code PreferencesReader}.
051     * @param file the file
052     * @param defaults true when reading from the cache file for default preferences,
053     * false for the regular preferences config file
054     * @throws IOException if any I/O error occurs
055     * @throws XMLStreamException if any XML stream error occurs
056     */
057    public PreferencesReader(File file, boolean defaults) throws IOException, XMLStreamException {
058        this.defaults = defaults;
059        this.reader = null;
060        this.file = file;
061    }
062
063    /**
064     * Constructs a new {@code PreferencesReader}.
065     * @param reader the {@link Reader}
066     * @param defaults true when reading from the cache file for default preferences,
067     * false for the regular preferences config file
068     * @throws XMLStreamException if any XML stream error occurs
069     */
070    public PreferencesReader(Reader reader, boolean defaults) throws XMLStreamException {
071        this.defaults = defaults;
072        this.reader = reader;
073        this.file = null;
074    }
075
076    /**
077     * Validate the XML.
078     * @param f the file
079     * @throws IOException if any I/O error occurs
080     * @throws SAXException if any SAX error occurs
081     */
082    public static void validateXML(File f) throws IOException, SAXException {
083        try (BufferedReader in = Files.newBufferedReader(f.toPath(), StandardCharsets.UTF_8)) {
084            validateXML(in);
085        }
086    }
087
088    /**
089     * Validate the XML.
090     * @param in the {@link Reader}
091     * @throws IOException if any I/O error occurs
092     * @throws SAXException if any SAX error occurs
093     */
094    public static void validateXML(Reader in) throws IOException, SAXException {
095        try (CachedFile cf = new CachedFile("resource://data/preferences.xsd"); InputStream xsdStream = cf.getInputStream()) {
096            Schema schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI).newSchema(new StreamSource(xsdStream));
097            Validator validator = schema.newValidator();
098            validator.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 = XMLInputFactory.newInstance().createXMLStreamReader(reader);
127            doParse();
128        } else {
129            try (BufferedReader in = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)) {
130                this.parser = XMLInputFactory.newInstance().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                    if (Main.isDebugEnabled()) {
151                        Main.debug(e.getMessage());
152                    }
153                }
154                parseRoot();
155            } else if (event == XMLStreamConstants.END_ELEMENT) {
156                return;
157            }
158            if (parser.hasNext()) {
159                event = parser.next();
160            } else {
161                break;
162            }
163        }
164        parser.close();
165    }
166
167    private void parseRoot() throws XMLStreamException {
168        while (true) {
169            int event = parser.next();
170            if (event == XMLStreamConstants.START_ELEMENT) {
171                String localName = parser.getLocalName();
172                switch(localName) {
173                case "tag":
174                    StringSetting setting;
175                    if (defaults && isNil()) {
176                        setting = new StringSetting(null);
177                    } else {
178                        String value = parser.getAttributeValue(null, "value");
179                        if (value == null) {
180                            throw new XMLStreamException(tr("value expected"), parser.getLocation());
181                        }
182                        setting = new StringSetting(value);
183                    }
184                    if (defaults) {
185                        setting.setTime(Math.round(Double.parseDouble(parser.getAttributeValue(null, "time"))));
186                    }
187                    settings.put(parser.getAttributeValue(null, "key"), setting);
188                    jumpToEnd();
189                    break;
190                case "list":
191                case "lists":
192                case "maps":
193                    parseToplevelList();
194                    break;
195                default:
196                    throwException("Unexpected element: "+localName);
197                }
198            } else if (event == XMLStreamConstants.END_ELEMENT) {
199                return;
200            }
201        }
202    }
203
204    private void jumpToEnd() throws XMLStreamException {
205        while (true) {
206            int event = parser.next();
207            if (event == XMLStreamConstants.START_ELEMENT) {
208                jumpToEnd();
209            } else if (event == XMLStreamConstants.END_ELEMENT) {
210                return;
211            }
212        }
213    }
214
215    private void parseToplevelList() throws XMLStreamException {
216        String key = parser.getAttributeValue(null, "key");
217        Long time = null;
218        if (defaults) {
219            time = Math.round(Double.parseDouble(parser.getAttributeValue(null, "time")));
220        }
221        String name = parser.getLocalName();
222
223        List<String> entries = null;
224        List<List<String>> lists = null;
225        List<Map<String, String>> maps = null;
226        if (defaults && isNil()) {
227            Setting<?> setting;
228            switch (name) {
229                case "lists":
230                    setting = new ListListSetting(null);
231                    break;
232                case "maps":
233                    setting = new MapListSetting(null);
234                    break;
235                default:
236                    setting = new ListSetting(null);
237                    break;
238            }
239            setting.setTime(time);
240            settings.put(key, setting);
241            jumpToEnd();
242        } else {
243            while (true) {
244                int event = parser.next();
245                if (event == XMLStreamConstants.START_ELEMENT) {
246                    String localName = parser.getLocalName();
247                    switch(localName) {
248                    case "entry":
249                        if (entries == null) {
250                            entries = new ArrayList<>();
251                        }
252                        entries.add(parser.getAttributeValue(null, "value"));
253                        jumpToEnd();
254                        break;
255                    case "list":
256                        if (lists == null) {
257                            lists = new ArrayList<>();
258                        }
259                        lists.add(parseInnerList());
260                        break;
261                    case "map":
262                        if (maps == null) {
263                            maps = new ArrayList<>();
264                        }
265                        maps.add(parseMap());
266                        break;
267                    default:
268                        throwException("Unexpected element: "+localName);
269                    }
270                } else if (event == XMLStreamConstants.END_ELEMENT) {
271                    break;
272                }
273            }
274            Setting<?> setting;
275            if (entries != null) {
276                setting = new ListSetting(Collections.unmodifiableList(entries));
277            } else if (lists != null) {
278                setting = new ListListSetting(Collections.unmodifiableList(lists));
279            } else if (maps != null) {
280                setting = new MapListSetting(Collections.unmodifiableList(maps));
281            } else {
282                switch (name) {
283                    case "lists":
284                        setting = new ListListSetting(Collections.<List<String>>emptyList());
285                        break;
286                    case "maps":
287                        setting = new MapListSetting(Collections.<Map<String, String>>emptyList());
288                        break;
289                    default:
290                        setting = new ListSetting(Collections.<String>emptyList());
291                        break;
292                }
293            }
294            if (defaults) {
295                setting.setTime(time);
296            }
297            settings.put(key, setting);
298        }
299    }
300
301    private List<String> parseInnerList() throws XMLStreamException {
302        List<String> entries = new ArrayList<>();
303        while (true) {
304            int event = parser.next();
305            if (event == XMLStreamConstants.START_ELEMENT) {
306                if ("entry".equals(parser.getLocalName())) {
307                    entries.add(parser.getAttributeValue(null, "value"));
308                    jumpToEnd();
309                } else {
310                    throwException("Unexpected element: "+parser.getLocalName());
311                }
312            } else if (event == XMLStreamConstants.END_ELEMENT) {
313                break;
314            }
315        }
316        return Collections.unmodifiableList(entries);
317    }
318
319    private Map<String, String> parseMap() throws XMLStreamException {
320        Map<String, String> map = new LinkedHashMap<>();
321        while (true) {
322            int event = parser.next();
323            if (event == XMLStreamConstants.START_ELEMENT) {
324                if ("tag".equals(parser.getLocalName())) {
325                    map.put(parser.getAttributeValue(null, "key"), parser.getAttributeValue(null, "value"));
326                    jumpToEnd();
327                } else {
328                    throwException("Unexpected element: "+parser.getLocalName());
329                }
330            } else if (event == XMLStreamConstants.END_ELEMENT) {
331                break;
332            }
333        }
334        return Collections.unmodifiableMap(map);
335    }
336
337    /**
338     * Check if the current element is nil (meaning the value of the setting is null).
339     * @return true, if the current element is nil
340     * @see <a href="https://msdn.microsoft.com/en-us/library/2b314yt2(v=vs.85).aspx">Nillable Attribute on MS Developer Network</a>
341     */
342    private boolean isNil() {
343        String nil = parser.getAttributeValue(XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI, "nil");
344        return "true".equals(nil) || "1".equals(nil);
345    }
346
347    /**
348     * Throw XmlStreamParsingException with line and column number.
349     *
350     * Only use this for errors that should not be possible after schema validation.
351     * @param msg the error message
352     * @throws XmlStreamParsingException always
353     */
354    private void throwException(String msg) throws XmlStreamParsingException {
355        throw new XmlStreamParsingException(msg, parser.getLocation());
356    }
357}