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