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}