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