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