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