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