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