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}