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