001// License: GPL. See LICENSE file for details.
002package org.openstreetmap.josm.data.validation.tests;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.io.InputStreamReader;
007import java.util.ArrayList;
008import java.util.Arrays;
009import java.util.Collections;
010import java.util.List;
011
012import javax.script.Invocable;
013import javax.script.ScriptEngine;
014import javax.script.ScriptEngineManager;
015import javax.script.ScriptException;
016
017import org.openstreetmap.josm.Main;
018import org.openstreetmap.josm.command.ChangePropertyCommand;
019import org.openstreetmap.josm.data.osm.Node;
020import org.openstreetmap.josm.data.osm.OsmPrimitive;
021import org.openstreetmap.josm.data.osm.Relation;
022import org.openstreetmap.josm.data.osm.Way;
023import org.openstreetmap.josm.data.validation.FixableTestError;
024import org.openstreetmap.josm.data.validation.Severity;
025import org.openstreetmap.josm.data.validation.Test;
026import org.openstreetmap.josm.data.validation.TestError;
027import org.openstreetmap.josm.io.MirroredInputStream;
028
029/**
030 * Tests the correct usage of the opening hour syntax of the tags
031 * {@code opening_hours}, {@code collection_times}, {@code service_times} according to
032 * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a>.
033 *
034 * @since 6370
035 */
036public class OpeningHourTest extends Test {
037
038    /**
039     * Javascript engine
040     */
041    public static final ScriptEngine ENGINE = new ScriptEngineManager().getEngineByName("JavaScript");
042
043    /**
044     * Constructs a new {@code OpeningHourTest}.
045     */
046    public OpeningHourTest() {
047        super(tr("Opening hours syntax"),
048                tr("This test checks the correct usage of the opening hours syntax."));
049    }
050
051    @Override
052    public void initialize() throws Exception {
053        super.initialize();
054        if (ENGINE != null) {
055            ENGINE.eval(new InputStreamReader(new MirroredInputStream("resource://data/validator/opening_hours.js"), "UTF-8"));
056            // fake country/state to not get errors on holidays
057            ENGINE.eval("var nominatimJSON = {address: {state: 'Bayern', country_code: 'de'}};");
058            ENGINE.eval("" +
059                    "var oh = function (value, mode) {" +
060                    " try {" +
061                    "    var r= new opening_hours(value, nominatimJSON, mode);" +
062                    "    r.getErrors = function() {return [];};" +
063                    "    return r;" +
064                    "  } catch(err) {" +
065                    "    return {" +
066                    "      getWarnings: function() {return [];}," +
067                    "      getErrors: function() {return [err.toString()]}" +
068                    "    };" +
069                    "  }" +
070                    "};");
071        } else {
072            Main.warn("Unable to initialize OpeningHourTest because no JavaScript engine has been found");
073        }
074    }
075
076    static enum CheckMode {
077        TIME_RANGE(0), POINTS_IN_TIME(1), BOTH(2);
078        final int code;
079
080        CheckMode(int code) {
081            this.code = code;
082        }
083    }
084
085    protected Object parse(String value, CheckMode mode) throws ScriptException, NoSuchMethodException {
086        return ((Invocable) ENGINE).invokeFunction("oh", value, mode.code);
087    }
088
089    @SuppressWarnings("unchecked")
090    protected List<Object> getList(Object obj) throws ScriptException, NoSuchMethodException {
091        if (obj == null || "".equals(obj)) {
092            return Arrays.asList();
093        } else if (obj instanceof String) {
094            final Object[] strings = ((String) obj).split("\\\\n");
095            return Arrays.asList(strings);
096        } else if (obj instanceof List) {
097            return (List<Object>) obj;
098        } else {
099            // recursively call getList() with argument converted to newline-separated string
100            return getList(((Invocable) ENGINE).invokeMethod(obj, "join", "\\n"));
101        }
102    }
103
104    /**
105     * An error concerning invalid syntax for an "opening_hours"-like tag.
106     */
107    public class OpeningHoursTestError {
108        final Severity severity;
109        final String message, prettifiedValue;
110
111        /**
112         * Constructs a new {@code OpeningHoursTestError} with a known pretiffied value.
113         * @param message The error message
114         * @param severity The error severity
115         * @param prettifiedValue The prettified value
116         */
117        public OpeningHoursTestError(String message, Severity severity, String prettifiedValue) {
118            this.message = message;
119            this.severity = severity;
120            this.prettifiedValue = prettifiedValue;
121        }
122
123        /**
124         * Constructs a new {@code OpeningHoursTestError}.
125         * @param message The error message
126         * @param severity The error severity
127         */
128        public OpeningHoursTestError(String message, Severity severity) {
129            this(message, severity, null);
130        }
131
132        /**
133         * Returns the real test error given to JOSM validator.
134         * @param p The incriminated OSM primitive.
135         * @param key The incriminated key, used for display.
136         * @return The real test error given to JOSM validator. Can be fixable or not if a prettified values has been determined.
137         */
138        public TestError getTestError(final OsmPrimitive p, final String key) {
139            if (prettifiedValue == null) {
140                return new TestError(OpeningHourTest.this, severity, message, 2901, p);
141            } else {
142                return new FixableTestError(OpeningHourTest.this, severity, message, 2901, p,
143                        new ChangePropertyCommand(p, key, prettifiedValue));
144            }
145        }
146
147        /**
148         * Returns the error message.
149         * @return The error message.
150         */
151        public String getMessage() {
152            return message;
153        }
154
155        /**
156         * Returns the prettified value.
157         * @return The prettified value.
158         */
159        public String getPrettifiedValue() {
160            return prettifiedValue;
161        }
162
163        /**
164         * Returns the error severity.
165         * @return The error severity.
166         */
167        public Severity getSeverity() {
168            return severity;
169        }
170    }
171
172    /**
173     * Checks for a correct usage of the opening hour syntax of the {@code value} given according to
174     * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns a list containing
175     * validation errors or an empty list. Null values result in an empty list.
176     * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times"). Used in error message
177     * @param value the opening hour value to be checked.
178     * @param mode whether to validate {@code value} as a time range, or points in time, or both.
179     * @return a list of {@link TestError} or an empty list
180     */
181    public List<OpeningHoursTestError> checkOpeningHourSyntax(final String key, final String value, CheckMode mode) {
182        if (ENGINE == null || value == null || value.trim().isEmpty()) {
183            return Collections.emptyList();
184        }
185        try {
186            final Object r = parse(value, mode);
187            final List<OpeningHoursTestError> errors = new ArrayList<OpeningHoursTestError>();
188            String prettifiedValue = null;
189            try {
190                prettifiedValue = (String) ((Invocable) ENGINE).invokeMethod(r, "prettifyValue");
191            } catch (Exception e) {
192                Main.debug(e.getMessage());
193            }
194            for (final Object i : getList(((Invocable) ENGINE).invokeMethod(r, "getErrors"))) {
195                errors.add(new OpeningHoursTestError(key + " - " + i.toString().trim(), Severity.ERROR, prettifiedValue));
196            }
197            for (final Object i : getList(((Invocable) ENGINE).invokeMethod(r, "getWarnings"))) {
198                errors.add(new OpeningHoursTestError(i.toString().trim(), Severity.WARNING, prettifiedValue));
199            }
200            return errors;
201        } catch (final Exception ex) {
202            throw new RuntimeException(ex);
203        }
204    }
205
206    /**
207     * Checks for a correct usage of the opening hour syntax of the {@code value} given, in time range mode, according to
208     * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns a list containing
209     * validation errors or an empty list. Null values result in an empty list.
210     * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times"). Used in error message
211     * @param value the opening hour value to be checked.
212     * @return a list of {@link TestError} or an empty list
213     */
214    public List<OpeningHoursTestError> checkOpeningHourSyntax(final String key, final String value) {
215        return checkOpeningHourSyntax(key, value, CheckMode.TIME_RANGE);
216    }
217
218    protected void check(final OsmPrimitive p, final String key, CheckMode mode) {
219        for (OpeningHoursTestError e : checkOpeningHourSyntax(key, p.get(key), mode)) {
220            errors.add(e.getTestError(p, key));
221        }
222    }
223
224    protected void check(final OsmPrimitive p) {
225        check(p, "opening_hours", CheckMode.TIME_RANGE);
226        check(p, "collection_times", CheckMode.BOTH);
227        check(p, "service_times", CheckMode.BOTH);
228    }
229
230    @Override
231    public void visit(final Node n) {
232        check(n);
233    }
234
235    @Override
236    public void visit(final Relation r) {
237        check(r);
238    }
239
240    @Override
241    public void visit(final Way w) {
242        check(w);
243    }
244}