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}