001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.tagging.presets; 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.InputStreamReader; 011import java.io.Reader; 012import java.util.ArrayDeque; 013import java.util.ArrayList; 014import java.util.Collection; 015import java.util.Deque; 016import java.util.HashMap; 017import java.util.Iterator; 018import java.util.LinkedHashSet; 019import java.util.LinkedList; 020import java.util.List; 021import java.util.Map; 022import java.util.Set; 023 024import javax.swing.JOptionPane; 025 026import org.openstreetmap.josm.data.preferences.sources.PresetPrefHelper; 027import org.openstreetmap.josm.gui.MainApplication; 028import org.openstreetmap.josm.gui.tagging.presets.items.Check; 029import org.openstreetmap.josm.gui.tagging.presets.items.CheckGroup; 030import org.openstreetmap.josm.gui.tagging.presets.items.Combo; 031import org.openstreetmap.josm.gui.tagging.presets.items.ComboMultiSelect; 032import org.openstreetmap.josm.gui.tagging.presets.items.ItemSeparator; 033import org.openstreetmap.josm.gui.tagging.presets.items.Key; 034import org.openstreetmap.josm.gui.tagging.presets.items.Label; 035import org.openstreetmap.josm.gui.tagging.presets.items.Link; 036import org.openstreetmap.josm.gui.tagging.presets.items.MultiSelect; 037import org.openstreetmap.josm.gui.tagging.presets.items.Optional; 038import org.openstreetmap.josm.gui.tagging.presets.items.PresetLink; 039import org.openstreetmap.josm.gui.tagging.presets.items.PresetListEntry; 040import org.openstreetmap.josm.gui.tagging.presets.items.Roles; 041import org.openstreetmap.josm.gui.tagging.presets.items.Roles.Role; 042import org.openstreetmap.josm.gui.tagging.presets.items.Space; 043import org.openstreetmap.josm.gui.tagging.presets.items.Text; 044import org.openstreetmap.josm.io.CachedFile; 045import org.openstreetmap.josm.io.NetworkManager; 046import org.openstreetmap.josm.io.UTFInputStreamReader; 047import org.openstreetmap.josm.spi.preferences.Config; 048import org.openstreetmap.josm.tools.I18n; 049import org.openstreetmap.josm.tools.Logging; 050import org.openstreetmap.josm.tools.Stopwatch; 051import org.openstreetmap.josm.tools.Utils; 052import org.openstreetmap.josm.tools.XmlObjectParser; 053import org.xml.sax.SAXException; 054 055/** 056 * The tagging presets reader. 057 * @since 6068 058 */ 059public final class TaggingPresetReader { 060 061 /** 062 * The accepted MIME types sent in the HTTP Accept header. 063 * @since 6867 064 */ 065 public static final String PRESET_MIME_TYPES = 066 "application/xml, text/xml, text/plain; q=0.8, application/zip, application/octet-stream; q=0.5"; 067 068 /** 069 * The XML namespace for the tagging presets 070 * @since 16640 071 */ 072 public static final String NAMESPACE = Config.getUrls().getXMLBase() + "/tagging-preset-1.0"; 073 074 /** 075 * The internal resource URL of the XML schema file to be used with {@link CachedFile} 076 * @since 16640 077 */ 078 public static final String SCHEMA_SOURCE = "resource://data/tagging-preset.xsd"; 079 080 private static volatile File zipIcons; 081 private static volatile boolean loadIcons = true; 082 083 /** 084 * Holds a reference to a chunk of items/objects. 085 */ 086 public static class Chunk { 087 /** The chunk id, can be referenced later */ 088 public String id; 089 090 @Override 091 public String toString() { 092 return "Chunk [id=" + id + ']'; 093 } 094 } 095 096 /** 097 * Holds a reference to an earlier item/object. 098 */ 099 public static class Reference { 100 /** Reference matching a chunk id defined earlier **/ 101 public String ref; 102 103 @Override 104 public String toString() { 105 return "Reference [ref=" + ref + ']'; 106 } 107 } 108 109 static class HashSetWithLast<E> extends LinkedHashSet<E> { 110 private static final long serialVersionUID = 1L; 111 protected transient E last; 112 113 @Override 114 public boolean add(E e) { 115 last = e; 116 return super.add(e); 117 } 118 119 /** 120 * Returns the last inserted element. 121 * @return the last inserted element 122 */ 123 public E getLast() { 124 return last; 125 } 126 } 127 128 /** 129 * Returns the set of preset source URLs. 130 * @return The set of preset source URLs. 131 */ 132 public static Set<String> getPresetSources() { 133 return new PresetPrefHelper().getActiveUrls(); 134 } 135 136 private static XmlObjectParser buildParser() { 137 XmlObjectParser parser = new XmlObjectParser(); 138 parser.mapOnStart("item", TaggingPreset.class); 139 parser.mapOnStart("separator", TaggingPresetSeparator.class); 140 parser.mapBoth("group", TaggingPresetMenu.class); 141 parser.map("text", Text.class); 142 parser.map("link", Link.class); 143 parser.map("preset_link", PresetLink.class); 144 parser.mapOnStart("optional", Optional.class); 145 parser.mapOnStart("roles", Roles.class); 146 parser.map("role", Role.class); 147 parser.mapBoth("checkgroup", CheckGroup.class); 148 parser.map("check", Check.class); 149 parser.map("combo", Combo.class); 150 parser.map("multiselect", MultiSelect.class); 151 parser.map("label", Label.class); 152 parser.map("space", Space.class); 153 parser.map("key", Key.class); 154 parser.map("list_entry", PresetListEntry.class); 155 parser.map("item_separator", ItemSeparator.class); 156 parser.mapBoth("chunk", Chunk.class); 157 parser.map("reference", Reference.class); 158 return parser; 159 } 160 161 /** 162 * Reads all tagging presets from the input reader. 163 * @param in The input reader 164 * @param validate if {@code true}, XML validation will be performed 165 * @return collection of tagging presets 166 * @throws SAXException if any XML error occurs 167 */ 168 public static Collection<TaggingPreset> readAll(Reader in, boolean validate) throws SAXException { 169 return readAll(in, validate, new HashSetWithLast<TaggingPreset>()); 170 } 171 172 /** 173 * Reads all tagging presets from the input reader. 174 * @param in The input reader 175 * @param validate if {@code true}, XML validation will be performed 176 * @param all the accumulator for parsed tagging presets 177 * @return the accumulator 178 * @throws SAXException if any XML error occurs 179 */ 180 static Collection<TaggingPreset> readAll(Reader in, boolean validate, HashSetWithLast<TaggingPreset> all) throws SAXException { 181 XmlObjectParser parser = buildParser(); 182 183 /** to detect end of {@code <checkgroup>} */ 184 CheckGroup lastcheckgroup = null; 185 /** to detect end of {@code <group>} */ 186 TaggingPresetMenu lastmenu = null; 187 /** to detect end of reused {@code <group>} */ 188 TaggingPresetMenu lastmenuOriginal = null; 189 Roles lastrole = null; 190 final List<Check> checks = new LinkedList<>(); 191 final List<PresetListEntry> listEntries = new LinkedList<>(); 192 final Map<String, List<Object>> byId = new HashMap<>(); 193 final Deque<String> lastIds = new ArrayDeque<>(); 194 /** lastIdIterators contains non empty iterators of items to be handled before obtaining the next item from the XML parser */ 195 final Deque<Iterator<Object>> lastIdIterators = new ArrayDeque<>(); 196 197 if (validate) { 198 parser.startWithValidation(in, NAMESPACE, SCHEMA_SOURCE); 199 } else { 200 parser.start(in); 201 } 202 while (parser.hasNext() || !lastIdIterators.isEmpty()) { 203 final Object o; 204 if (!lastIdIterators.isEmpty()) { 205 // obtain elements from lastIdIterators with higher priority 206 o = lastIdIterators.peek().next(); 207 if (!lastIdIterators.peek().hasNext()) { 208 // remove iterator if is empty 209 lastIdIterators.pop(); 210 } 211 } else { 212 o = parser.next(); 213 } 214 Logging.trace("Preset object: {0}", o); 215 if (o instanceof Chunk) { 216 if (!lastIds.isEmpty() && ((Chunk) o).id.equals(lastIds.peek())) { 217 // pop last id on end of object, don't process further 218 lastIds.pop(); 219 ((Chunk) o).id = null; 220 continue; 221 } else { 222 // if preset item contains an id, store a mapping for later usage 223 String lastId = ((Chunk) o).id; 224 lastIds.push(lastId); 225 byId.put(lastId, new ArrayList<>()); 226 continue; 227 } 228 } else if (!lastIds.isEmpty()) { 229 // add object to mapping for later usage 230 byId.get(lastIds.peek()).add(o); 231 continue; 232 } 233 if (o instanceof Reference) { 234 // if o is a reference, obtain the corresponding objects from the mapping, 235 // and iterate over those before consuming the next element from parser. 236 final String ref = ((Reference) o).ref; 237 if (byId.get(ref) == null) { 238 throw new SAXException(tr("Reference {0} is being used before it was defined", ref)); 239 } 240 Iterator<Object> it = byId.get(ref).iterator(); 241 if (it.hasNext()) { 242 lastIdIterators.push(it); 243 if (lastIdIterators.size() > 100) { 244 throw new SAXException(tr("Reference stack for {0} is too large", ref)); 245 } 246 } else { 247 Logging.warn("Ignoring reference '"+ref+"' denoting an empty chunk"); 248 } 249 continue; 250 } 251 if (!(o instanceof TaggingPresetItem) && !checks.isEmpty()) { 252 all.getLast().data.addAll(checks); 253 checks.clear(); 254 } 255 if (o instanceof TaggingPresetMenu) { 256 TaggingPresetMenu tp = (TaggingPresetMenu) o; 257 if (tp == lastmenu || tp == lastmenuOriginal) { 258 lastmenu = tp.group; 259 } else { 260 tp.group = lastmenu; 261 if (all.contains(tp)) { 262 lastmenuOriginal = tp; 263 tp = (TaggingPresetMenu) all.stream().filter(tp::equals).findFirst().orElse(tp); 264 lastmenuOriginal.group = null; 265 } else { 266 tp.setDisplayName(); 267 all.add(tp); 268 lastmenuOriginal = null; 269 } 270 lastmenu = tp; 271 } 272 lastrole = null; 273 } else if (o instanceof TaggingPresetSeparator) { 274 TaggingPresetSeparator tp = (TaggingPresetSeparator) o; 275 tp.group = lastmenu; 276 all.add(tp); 277 lastrole = null; 278 } else if (o instanceof TaggingPreset) { 279 TaggingPreset tp = (TaggingPreset) o; 280 tp.group = lastmenu; 281 tp.setDisplayName(); 282 all.add(tp); 283 lastrole = null; 284 } else { 285 if (!all.isEmpty()) { 286 if (o instanceof Roles) { 287 all.getLast().data.add((TaggingPresetItem) o); 288 if (all.getLast().roles != null) { 289 throw new SAXException(tr("Roles cannot appear more than once")); 290 } 291 all.getLast().roles = (Roles) o; 292 lastrole = (Roles) o; 293 // #16458 - Make sure we don't duplicate role entries if used in a chunk/reference 294 lastrole.roles.clear(); 295 } else if (o instanceof Role) { 296 if (lastrole == null) 297 throw new SAXException(tr("Preset role element without parent")); 298 lastrole.roles.add((Role) o); 299 } else if (o instanceof Check) { 300 if (lastcheckgroup != null) { 301 checks.add((Check) o); 302 } else { 303 all.getLast().data.add((TaggingPresetItem) o); 304 } 305 } else if (o instanceof PresetListEntry) { 306 listEntries.add((PresetListEntry) o); 307 } else if (o instanceof CheckGroup) { 308 CheckGroup cg = (CheckGroup) o; 309 if (cg == lastcheckgroup) { 310 lastcheckgroup = null; 311 all.getLast().data.add(cg); 312 // Make sure list of checks is empty to avoid adding checks several times 313 // when used in chunks (fix #10801) 314 cg.checks.clear(); 315 cg.checks.addAll(checks); 316 checks.clear(); 317 } else { 318 lastcheckgroup = cg; 319 } 320 } else { 321 if (!checks.isEmpty()) { 322 all.getLast().data.addAll(checks); 323 checks.clear(); 324 } 325 all.getLast().data.add((TaggingPresetItem) o); 326 if (o instanceof ComboMultiSelect) { 327 ((ComboMultiSelect) o).addListEntries(listEntries); 328 } else if (o instanceof Key && ((Key) o).value == null) { 329 ((Key) o).value = ""; // Fix #8530 330 } 331 listEntries.clear(); 332 lastrole = null; 333 } 334 } else 335 throw new SAXException(tr("Preset sub element without parent")); 336 } 337 } 338 if (!all.isEmpty() && !checks.isEmpty()) { 339 all.getLast().data.addAll(checks); 340 checks.clear(); 341 } 342 return all; 343 } 344 345 /** 346 * Reads all tagging presets from the given source. 347 * @param source a given filename, URL or internal resource 348 * @param validate if {@code true}, XML validation will be performed 349 * @return collection of tagging presets 350 * @throws SAXException if any XML error occurs 351 * @throws IOException if any I/O error occurs 352 */ 353 public static Collection<TaggingPreset> readAll(String source, boolean validate) throws SAXException, IOException { 354 return readAll(source, validate, new HashSetWithLast<TaggingPreset>()); 355 } 356 357 /** 358 * Reads all tagging presets from the given source. 359 * @param source a given filename, URL or internal resource 360 * @param validate if {@code true}, XML validation will be performed 361 * @param all the accumulator for parsed tagging presets 362 * @return the accumulator 363 * @throws SAXException if any XML error occurs 364 * @throws IOException if any I/O error occurs 365 */ 366 static Collection<TaggingPreset> readAll(String source, boolean validate, HashSetWithLast<TaggingPreset> all) 367 throws SAXException, IOException { 368 Collection<TaggingPreset> tp; 369 Logging.debug("Reading presets from {0}", source); 370 Stopwatch stopwatch = Stopwatch.createStarted(); 371 try ( 372 CachedFile cf = new CachedFile(source).setHttpAccept(PRESET_MIME_TYPES); 373 // zip may be null, but Java 7 allows it: https://blogs.oracle.com/darcy/entry/project_coin_null_try_with 374 InputStream zip = cf.findZipEntryInputStream("xml", "preset") 375 ) { 376 if (zip != null) { 377 zipIcons = cf.getFile(); 378 I18n.addTexts(zipIcons); 379 } 380 try (InputStreamReader r = UTFInputStreamReader.create(zip == null ? cf.getInputStream() : zip)) { 381 tp = readAll(new BufferedReader(r), validate, all); 382 } 383 } 384 Logging.debug(stopwatch.toString("Reading presets")); 385 return tp; 386 } 387 388 /** 389 * Reads all tagging presets from the given sources. 390 * @param sources Collection of tagging presets sources. 391 * @param validate if {@code true}, presets will be validated against XML schema 392 * @return Collection of all presets successfully read 393 */ 394 public static Collection<TaggingPreset> readAll(Collection<String> sources, boolean validate) { 395 return readAll(sources, validate, true); 396 } 397 398 /** 399 * Reads all tagging presets from the given sources. 400 * @param sources Collection of tagging presets sources. 401 * @param validate if {@code true}, presets will be validated against XML schema 402 * @param displayErrMsg if {@code true}, a blocking error message is displayed in case of I/O exception. 403 * @return Collection of all presets successfully read 404 */ 405 public static Collection<TaggingPreset> readAll(Collection<String> sources, boolean validate, boolean displayErrMsg) { 406 HashSetWithLast<TaggingPreset> allPresets = new HashSetWithLast<>(); 407 for (String source : sources) { 408 try { 409 readAll(source, validate, allPresets); 410 } catch (IOException e) { 411 Logging.log(Logging.LEVEL_ERROR, e); 412 Logging.error(source); 413 if (source.startsWith("http")) { 414 NetworkManager.addNetworkError(source, e); 415 } 416 if (displayErrMsg) { 417 JOptionPane.showMessageDialog( 418 MainApplication.getMainFrame(), 419 tr("Could not read tagging preset source: {0}", source), 420 tr("Error"), 421 JOptionPane.ERROR_MESSAGE 422 ); 423 } 424 } catch (SAXException | IllegalArgumentException e) { 425 Logging.error(e); 426 Logging.error(source); 427 if (displayErrMsg) { 428 JOptionPane.showMessageDialog( 429 MainApplication.getMainFrame(), 430 "<html>" + tr("Error parsing {0}: ", source) + "<br><br><table width=600>" + 431 Utils.escapeReservedCharactersHTML(e.getMessage()) + "</table></html>", 432 tr("Error"), 433 JOptionPane.ERROR_MESSAGE 434 ); 435 } 436 } 437 } 438 return allPresets; 439 } 440 441 /** 442 * Reads all tagging presets from sources stored in preferences. 443 * @param validate if {@code true}, presets will be validated against XML schema 444 * @param displayErrMsg if {@code true}, a blocking error message is displayed in case of I/O exception. 445 * @return Collection of all presets successfully read 446 */ 447 public static Collection<TaggingPreset> readFromPreferences(boolean validate, boolean displayErrMsg) { 448 return readAll(getPresetSources(), validate, displayErrMsg); 449 } 450 451 /** 452 * Returns the zip file where the icons are located 453 * @return the zip file where the icons are located 454 */ 455 public static File getZipIcons() { 456 return zipIcons; 457 } 458 459 /** 460 * Determines if icon images should be loaded. 461 * @return {@code true} if icon images should be loaded 462 */ 463 public static boolean isLoadIcons() { 464 return loadIcons; 465 } 466 467 /** 468 * Sets whether icon images should be loaded. 469 * @param loadIcons {@code true} if icon images should be loaded 470 */ 471 public static void setLoadIcons(boolean loadIcons) { 472 TaggingPresetReader.loadIcons = loadIcons; 473 } 474 475 private TaggingPresetReader() { 476 // Hide default constructor for utils classes 477 } 478}