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}