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