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.map("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 <group>} */ 170 TaggingPresetMenu lastmenu = null; 171 /** to detect end of reused {@code <group>} */ 172 TaggingPresetMenu lastmenuOriginal = null; 173 Roles lastrole = null; 174 final List<Check> checks = new LinkedList<>(); 175 final List<ComboMultiSelect.PresetListEntry> listEntries = new LinkedList<>(); 176 final Map<String, List<Object>> byId = new HashMap<>(); 177 final Deque<String> lastIds = new ArrayDeque<>(); 178 /** lastIdIterators contains non empty iterators of items to be handled before obtaining the next item from the XML parser */ 179 final Deque<Iterator<Object>> lastIdIterators = new ArrayDeque<>(); 180 181 if (validate) { 182 parser.startWithValidation(in, Config.getUrls().getXMLBase()+"/tagging-preset-1.0", "resource://data/tagging-preset.xsd"); 183 } else { 184 parser.start(in); 185 } 186 while (parser.hasNext() || !lastIdIterators.isEmpty()) { 187 final Object o; 188 if (!lastIdIterators.isEmpty()) { 189 // obtain elements from lastIdIterators with higher priority 190 o = lastIdIterators.peek().next(); 191 if (!lastIdIterators.peek().hasNext()) { 192 // remove iterator if is empty 193 lastIdIterators.pop(); 194 } 195 } else { 196 o = parser.next(); 197 } 198 Logging.trace("Preset object: {0}", o); 199 if (o instanceof Chunk) { 200 if (!lastIds.isEmpty() && ((Chunk) o).id.equals(lastIds.peek())) { 201 // pop last id on end of object, don't process further 202 lastIds.pop(); 203 ((Chunk) o).id = null; 204 continue; 205 } else { 206 // if preset item contains an id, store a mapping for later usage 207 String lastId = ((Chunk) o).id; 208 lastIds.push(lastId); 209 byId.put(lastId, new ArrayList<>()); 210 continue; 211 } 212 } else if (!lastIds.isEmpty()) { 213 // add object to mapping for later usage 214 byId.get(lastIds.peek()).add(o); 215 continue; 216 } 217 if (o instanceof Reference) { 218 // if o is a reference, obtain the corresponding objects from the mapping, 219 // and iterate over those before consuming the next element from parser. 220 final String ref = ((Reference) o).ref; 221 if (byId.get(ref) == null) { 222 throw new SAXException(tr("Reference {0} is being used before it was defined", ref)); 223 } 224 Iterator<Object> it = byId.get(ref).iterator(); 225 if (it.hasNext()) { 226 lastIdIterators.push(it); 227 } else { 228 Logging.warn("Ignoring reference '"+ref+"' denoting an empty chunk"); 229 } 230 continue; 231 } 232 if (!(o instanceof TaggingPresetItem) && !checks.isEmpty()) { 233 all.getLast().data.addAll(checks); 234 checks.clear(); 235 } 236 if (o instanceof TaggingPresetMenu) { 237 TaggingPresetMenu tp = (TaggingPresetMenu) o; 238 if (tp == lastmenu || tp == lastmenuOriginal) { 239 lastmenu = tp.group; 240 } else { 241 tp.group = lastmenu; 242 if (all.contains(tp)) { 243 lastmenuOriginal = tp; 244 java.util.Optional<TaggingPreset> val = all.stream().filter(tp::equals).findFirst(); 245 if (val.isPresent()) 246 tp = (TaggingPresetMenu) val.get(); 247 lastmenuOriginal.group = null; 248 } else { 249 tp.setDisplayName(); 250 all.add(tp); 251 lastmenuOriginal = null; 252 } 253 lastmenu = tp; 254 } 255 lastrole = null; 256 } else if (o instanceof TaggingPresetSeparator) { 257 TaggingPresetSeparator tp = (TaggingPresetSeparator) o; 258 tp.group = lastmenu; 259 all.add(tp); 260 lastrole = null; 261 } else if (o instanceof TaggingPreset) { 262 TaggingPreset tp = (TaggingPreset) o; 263 tp.group = lastmenu; 264 tp.setDisplayName(); 265 all.add(tp); 266 lastrole = null; 267 } else { 268 if (!all.isEmpty()) { 269 if (o instanceof Roles) { 270 all.getLast().data.add((TaggingPresetItem) o); 271 if (all.getLast().roles != null) { 272 throw new SAXException(tr("Roles cannot appear more than once")); 273 } 274 all.getLast().roles = (Roles) o; 275 lastrole = (Roles) o; 276 // #16458 - Make sure we don't duplicate role entries if used in a chunk/reference 277 lastrole.roles.clear(); 278 } else if (o instanceof Role) { 279 if (lastrole == null) 280 throw new SAXException(tr("Preset role element without parent")); 281 lastrole.roles.add((Role) o); 282 } else if (o instanceof Check) { 283 checks.add((Check) o); 284 } else if (o instanceof ComboMultiSelect.PresetListEntry) { 285 listEntries.add((ComboMultiSelect.PresetListEntry) o); 286 } else if (o instanceof CheckGroup) { 287 all.getLast().data.add((TaggingPresetItem) o); 288 // Make sure list of checks is empty to avoid adding checks several times 289 // when used in chunks (fix #10801) 290 ((CheckGroup) o).checks.clear(); 291 ((CheckGroup) o).checks.addAll(checks); 292 checks.clear(); 293 } else { 294 if (!checks.isEmpty()) { 295 all.getLast().data.addAll(checks); 296 checks.clear(); 297 } 298 all.getLast().data.add((TaggingPresetItem) o); 299 if (o instanceof ComboMultiSelect) { 300 ((ComboMultiSelect) o).addListEntries(listEntries); 301 } else if (o instanceof Key && ((Key) o).value == null) { 302 ((Key) o).value = ""; // Fix #8530 303 } 304 listEntries.clear(); 305 lastrole = null; 306 } 307 } else 308 throw new SAXException(tr("Preset sub element without parent")); 309 } 310 } 311 if (!all.isEmpty() && !checks.isEmpty()) { 312 all.getLast().data.addAll(checks); 313 checks.clear(); 314 } 315 return all; 316 } 317 318 /** 319 * Reads all tagging presets from the given source. 320 * @param source a given filename, URL or internal resource 321 * @param validate if {@code true}, XML validation will be performed 322 * @return collection of tagging presets 323 * @throws SAXException if any XML error occurs 324 * @throws IOException if any I/O error occurs 325 */ 326 public static Collection<TaggingPreset> readAll(String source, boolean validate) throws SAXException, IOException { 327 return readAll(source, validate, new HashSetWithLast<TaggingPreset>()); 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 * @param all the accumulator for parsed tagging presets 335 * @return the accumulator 336 * @throws SAXException if any XML error occurs 337 * @throws IOException if any I/O error occurs 338 */ 339 static Collection<TaggingPreset> readAll(String source, boolean validate, HashSetWithLast<TaggingPreset> all) 340 throws SAXException, IOException { 341 Collection<TaggingPreset> tp; 342 Logging.debug("Reading presets from {0}", source); 343 long startTime = System.currentTimeMillis(); 344 try ( 345 CachedFile cf = new CachedFile(source).setHttpAccept(PRESET_MIME_TYPES); 346 // zip may be null, but Java 7 allows it: https://blogs.oracle.com/darcy/entry/project_coin_null_try_with 347 InputStream zip = cf.findZipEntryInputStream("xml", "preset") 348 ) { 349 if (zip != null) { 350 zipIcons = cf.getFile(); 351 I18n.addTexts(zipIcons); 352 } 353 try (InputStreamReader r = UTFInputStreamReader.create(zip == null ? cf.getInputStream() : zip)) { 354 tp = readAll(new BufferedReader(r), validate, all); 355 } 356 } 357 if (Logging.isDebugEnabled()) { 358 Logging.debug("Presets read in {0}", Utils.getDurationString(System.currentTimeMillis() - startTime)); 359 } 360 return tp; 361 } 362 363 /** 364 * Reads all tagging presets from the given sources. 365 * @param sources Collection of tagging presets sources. 366 * @param validate if {@code true}, presets will be validated against XML schema 367 * @return Collection of all presets successfully read 368 */ 369 public static Collection<TaggingPreset> readAll(Collection<String> sources, boolean validate) { 370 return readAll(sources, validate, true); 371 } 372 373 /** 374 * Reads all tagging presets from the given sources. 375 * @param sources Collection of tagging presets sources. 376 * @param validate if {@code true}, presets will be validated against XML schema 377 * @param displayErrMsg if {@code true}, a blocking error message is displayed in case of I/O exception. 378 * @return Collection of all presets successfully read 379 */ 380 public static Collection<TaggingPreset> readAll(Collection<String> sources, boolean validate, boolean displayErrMsg) { 381 HashSetWithLast<TaggingPreset> allPresets = new HashSetWithLast<>(); 382 for (String source : sources) { 383 try { 384 readAll(source, validate, allPresets); 385 } catch (IOException e) { 386 Logging.log(Logging.LEVEL_ERROR, e); 387 Logging.error(source); 388 if (source.startsWith("http")) { 389 NetworkManager.addNetworkError(source, e); 390 } 391 if (displayErrMsg) { 392 JOptionPane.showMessageDialog( 393 MainApplication.getMainFrame(), 394 tr("Could not read tagging preset source: {0}", source), 395 tr("Error"), 396 JOptionPane.ERROR_MESSAGE 397 ); 398 } 399 } catch (SAXException | IllegalArgumentException e) { 400 Logging.error(e); 401 Logging.error(source); 402 if (displayErrMsg) { 403 JOptionPane.showMessageDialog( 404 MainApplication.getMainFrame(), 405 "<html>" + tr("Error parsing {0}: ", source) + "<br><br><table width=600>" + 406 Utils.escapeReservedCharactersHTML(e.getMessage()) + "</table></html>", 407 tr("Error"), 408 JOptionPane.ERROR_MESSAGE 409 ); 410 } 411 } 412 } 413 return allPresets; 414 } 415 416 /** 417 * Reads all tagging presets from sources stored in preferences. 418 * @param validate if {@code true}, presets will be validated against XML schema 419 * @param displayErrMsg if {@code true}, a blocking error message is displayed in case of I/O exception. 420 * @return Collection of all presets successfully read 421 */ 422 public static Collection<TaggingPreset> readFromPreferences(boolean validate, boolean displayErrMsg) { 423 return readAll(getPresetSources(), validate, displayErrMsg); 424 } 425 426 public static File getZipIcons() { 427 return zipIcons; 428 } 429 430 /** 431 * Determines if icon images should be loaded. 432 * @return {@code true} if icon images should be loaded 433 */ 434 public static boolean isLoadIcons() { 435 return loadIcons; 436 } 437 438 /** 439 * Sets whether icon images should be loaded. 440 * @param loadIcons {@code true} if icon images should be loaded 441 */ 442 public static void setLoadIcons(boolean loadIcons) { 443 TaggingPresetReader.loadIcons = loadIcons; 444 } 445 446 private TaggingPresetReader() { 447 // Hide default constructor for utils classes 448 } 449}