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}