// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.data.preferences;
import static org.openstreetmap.josm.tools.I18n.tr;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.SortedMap;
import java.util.TreeMap;
import javax.xml.XMLConstants;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import javax.xml.validation.Validator;
import org.openstreetmap.josm.Main;
import org.openstreetmap.josm.io.CachedFile;
import org.openstreetmap.josm.io.XmlStreamParsingException;
import org.xml.sax.SAXException;
/**
* Loads preferences from XML.
*/
public class PreferencesReader {
private final SortedMap<String, Setting<?>> settings = new TreeMap<>();
private XMLStreamReader parser;
private int version;
private final Reader reader;
private final File file;
private final boolean defaults;
/**
* Constructs a new {@code PreferencesReader}.
* @param file the file
* @param defaults true when reading from the cache file for default preferences,
* false for the regular preferences config file
*/
public PreferencesReader(File file, boolean defaults) {
this.defaults = defaults;
this.reader = null;
this.file = file;
}
/**
* Constructs a new {@code PreferencesReader}.
* @param reader the {@link Reader}
* @param defaults true when reading from the cache file for default preferences,
* false for the regular preferences config file
*/
public PreferencesReader(Reader reader, boolean defaults) {
this.defaults = defaults;
this.reader = reader;
this.file = null;
}
/**
* Validate the XML.
* @param f the file
* @throws IOException if any I/O error occurs
* @throws SAXException if any SAX error occurs
*/
public static void validateXML(File f) throws IOException, SAXException {
try (BufferedReader in = Files.newBufferedReader(f.toPath(), StandardCharsets.UTF_8)) {
validateXML(in);
}
}
/**
* Validate the XML.
* @param in the {@link Reader}
* @throws IOException if any I/O error occurs
* @throws SAXException if any SAX error occurs
*/
public static void validateXML(Reader in) throws IOException, SAXException {
try (CachedFile cf = new CachedFile("resource://data/preferences.xsd"); InputStream xsdStream = cf.getInputStream()) {
Schema schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI).newSchema(new StreamSource(xsdStream));
Validator validator = schema.newValidator();
validator.validate(new StreamSource(in));
}
}
/**
* Return the parsed preferences as a settings map
* @return the parsed preferences as a settings map
*/
public SortedMap<String, Setting<?>> getSettings() {
return settings;
}
/**
* Return the version from the XML root element.
* (Represents the JOSM version when the file was written.)
* @return the version
*/
public int getVersion() {
return version;
}
/**
* Parse preferences.
* @throws XMLStreamException if any XML parsing error occurs
* @throws IOException if any I/O error occurs
*/
public void parse() throws XMLStreamException, IOException {
if (reader != null) {
this.parser = XMLInputFactory.newInstance().createXMLStreamReader(reader);
doParse();
} else {
try (BufferedReader in = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)) {
this.parser = XMLInputFactory.newInstance().createXMLStreamReader(in);
doParse();
}
}
}
private void doParse() throws XMLStreamException {
int event = parser.getEventType();
while (true) {
if (event == XMLStreamConstants.START_ELEMENT) {
String topLevelElementName = defaults ? "preferences-defaults" : "preferences";
String localName = parser.getLocalName();
if (!topLevelElementName.equals(localName)) {
throw new XMLStreamException(
tr("Expected element ''{0}'', but got ''{1}''", topLevelElementName, localName),
parser.getLocation());
}
try {
version = Integer.parseInt(parser.getAttributeValue(null, "version"));
} catch (NumberFormatException e) {
if (Main.isDebugEnabled()) {
Main.debug(e.getMessage());
}
}
parseRoot();
} else if (event == XMLStreamConstants.END_ELEMENT) {
return;
}
if (parser.hasNext()) {
event = parser.next();
} else {
break;
}
}
parser.close();
}
private void parseRoot() throws XMLStreamException {
while (true) {
int event = parser.next();
if (event == XMLStreamConstants.START_ELEMENT) {
String localName = parser.getLocalName();
switch(localName) {
case "tag":
StringSetting setting;
if (defaults && isNil()) {
setting = new StringSetting(null);
} else {
setting = new StringSetting(Optional.ofNullable(parser.getAttributeValue(null, "value"))
.orElseThrow(() -> new XMLStreamException(tr("value expected"), parser.getLocation())));
}
if (defaults) {
setting.setTime(Math.round(Double.parseDouble(parser.getAttributeValue(null, "time"))));
}
settings.put(parser.getAttributeValue(null, "key"), setting);
jumpToEnd();
break;
case "list":
case "lists":
case "maps":
parseToplevelList();
break;
default:
throwException("Unexpected element: "+localName);
}
} else if (event == XMLStreamConstants.END_ELEMENT) {
return;
}
}
}
private void jumpToEnd() throws XMLStreamException {
while (true) {
int event = parser.next();
if (event == XMLStreamConstants.START_ELEMENT) {
jumpToEnd();
} else if (event == XMLStreamConstants.END_ELEMENT) {
return;
}
}
}
private void parseToplevelList() throws XMLStreamException {
String key = parser.getAttributeValue(null, "key");
Long time = null;
if (defaults) {
time = Math.round(Double.parseDouble(parser.getAttributeValue(null, "time")));
}
String name = parser.getLocalName();
List<String> entries = null;
List<List<String>> lists = null;
List<Map<String, String>> maps = null;
if (defaults && isNil()) {
Setting<?> setting;
switch (name) {
case "lists":
setting = new ListListSetting(null);
break;
case "maps":
setting = new MapListSetting(null);
break;
default:
setting = new ListSetting(null);
break;
}
setting.setTime(time);
settings.put(key, setting);
jumpToEnd();
} else {
while (true) {
int event = parser.next();
if (event == XMLStreamConstants.START_ELEMENT) {
String localName = parser.getLocalName();
switch(localName) {
case "entry":
if (entries == null) {
entries = new ArrayList<>();
}
entries.add(parser.getAttributeValue(null, "value"));
jumpToEnd();
break;
case "list":
if (lists == null) {
lists = new ArrayList<>();
}
lists.add(parseInnerList());
break;
case "map":
if (maps == null) {
maps = new ArrayList<>();
}
maps.add(parseMap());
break;
default:
throwException("Unexpected element: "+localName);
}
} else if (event == XMLStreamConstants.END_ELEMENT) {
break;
}
}
Setting<?> setting;
if (entries != null) {
setting = new ListSetting(Collections.unmodifiableList(entries));
} else if (lists != null) {
setting = new ListListSetting(Collections.unmodifiableList(lists));
} else if (maps != null) {
setting = new MapListSetting(Collections.unmodifiableList(maps));
} else {
switch (name) {
case "lists":
setting = new ListListSetting(Collections.<List<String>>emptyList());
break;
case "maps":
setting = new MapListSetting(Collections.<Map<String, String>>emptyList());
break;
default:
setting = new ListSetting(Collections.<String>emptyList());
break;
}
}
if (defaults) {
setting.setTime(time);
}
settings.put(key, setting);
}
}
private List<String> parseInnerList() throws XMLStreamException {
List<String> entries = new ArrayList<>();
while (true) {
int event = parser.next();
if (event == XMLStreamConstants.START_ELEMENT) {
if ("entry".equals(parser.getLocalName())) {
entries.add(parser.getAttributeValue(null, "value"));
jumpToEnd();
} else {
throwException("Unexpected element: "+parser.getLocalName());
}
} else if (event == XMLStreamConstants.END_ELEMENT) {
break;
}
}
return Collections.unmodifiableList(entries);
}
private Map<String, String> parseMap() throws XMLStreamException {
Map<String, String> map = new LinkedHashMap<>();
while (true) {
int event = parser.next();
if (event == XMLStreamConstants.START_ELEMENT) {
if ("tag".equals(parser.getLocalName())) {
map.put(parser.getAttributeValue(null, "key"), parser.getAttributeValue(null, "value"));
jumpToEnd();
} else {
throwException("Unexpected element: "+parser.getLocalName());
}
} else if (event == XMLStreamConstants.END_ELEMENT) {
break;
}
}
return Collections.unmodifiableMap(map);
}
/**
* Check if the current element is nil (meaning the value of the setting is null).
* @return true, if the current element is nil
* @see <a href="https://msdn.microsoft.com/en-us/library/2b314yt2(v=vs.85).aspx">Nillable Attribute on MS Developer Network</a>
*/
private boolean isNil() {
String nil = parser.getAttributeValue(XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI, "nil");
return "true".equals(nil) || "1".equals(nil);
}
/**
* Throw XmlStreamParsingException with line and column number.
*
* Only use this for errors that should not be possible after schema validation.
* @param msg the error message
* @throws XmlStreamParsingException always
*/
private void throwException(String msg) throws XmlStreamParsingException {
throw new XmlStreamParsingException(msg, parser.getLocation());
}
}