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