001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.validation.tests;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.io.Reader;
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.ScriptException;
015import javax.swing.JOptionPane;
016
017import org.openstreetmap.josm.command.ChangePropertyCommand;
018import org.openstreetmap.josm.data.osm.OsmPrimitive;
019import org.openstreetmap.josm.data.validation.Severity;
020import org.openstreetmap.josm.data.validation.Test;
021import org.openstreetmap.josm.data.validation.TestError;
022import org.openstreetmap.josm.gui.Notification;
023import org.openstreetmap.josm.io.CachedFile;
024import org.openstreetmap.josm.tools.LanguageInfo;
025import org.openstreetmap.josm.tools.Logging;
026import org.openstreetmap.josm.tools.Utils;
027
028/**
029 * Tests the correct usage of the opening hour syntax of the tags
030 * {@code opening_hours}, {@code collection_times}, {@code service_times} according to
031 * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a>.
032 *
033 * @since 6370
034 */
035public class OpeningHourTest extends Test.TagTest {
036
037    /**
038     * Javascript engine
039     */
040    public static final ScriptEngine ENGINE = Utils.getJavaScriptEngine();
041
042    /**
043     * Constructs a new {@code OpeningHourTest}.
044     */
045    public OpeningHourTest() {
046        super(tr("Opening hours syntax"),
047                tr("This test checks the correct usage of the opening hours syntax."));
048    }
049
050    @Override
051    public void initialize() throws Exception {
052        super.initialize();
053        if (ENGINE != null) {
054            try (CachedFile cf = new CachedFile("resource://data/validator/opening_hours.js");
055                 Reader reader = cf.getContentReader()) {
056                ENGINE.eval("var console={};console.debug=print;console.log=print;console.warn=print;console.error=print;");
057                ENGINE.eval(reader);
058                ENGINE.eval("var opening_hours = require('opening_hours');");
059                // fake country/state to not get errors on holidays
060                ENGINE.eval("var nominatimJSON = {address: {state: 'Bayern', country_code: 'de'}};");
061                ENGINE.eval(
062                        "var oh = function (value, tag_key, mode, locale) {" +
063                        " try {" +
064                        "    var conf = {tag_key: tag_key, locale: locale};" +
065                        "    if (mode > -1) {" +
066                        "      conf.mode = mode;" +
067                        "    }" +
068                        "    var r = new opening_hours(value, nominatimJSON, conf);" +
069                        "    r.getErrors = function() {return [];};" +
070                        "    return r;" +
071                        "  } catch (err) {" +
072                        "    return {" +
073                        "      prettifyValue: function() {return null;}," +
074                        "      getWarnings: function() {return [];}," +
075                        "      getErrors: function() {return [err.toString()]}" +
076                        "    };" +
077                        "  }" +
078                        "};");
079            }
080        } else {
081            Logging.warn("Unable to initialize OpeningHourTest because no JavaScript engine has been found");
082        }
083    }
084
085    /**
086     * In OSM, the syntax originally designed to describe opening hours, is now used to describe a few other things as well.
087     * Some of those other tags work with points in time instead of time ranges.
088     * To support this the mode can be specified.
089     * @since 13147
090     */
091    public enum CheckMode {
092        /** time ranges (opening_hours, lit, …) default */
093        TIME_RANGE(0),
094        /** points in time */
095        POINTS_IN_TIME(1),
096        /** both (time ranges and points in time, used by collection_times, service_times, …) */
097        BOTH(2);
098        private final int code;
099
100        CheckMode(int code) {
101            this.code = code;
102        }
103    }
104
105    /**
106     * Parses the opening hour syntax of the {@code value} given according to
107     * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns an object on which
108     * methods can be called to extract information.
109     * @param value the opening hour value to be checked
110     * @param tagKey the OSM key (should be "opening_hours", "collection_times" or "service_times")
111     * @param mode whether to validate {@code value} as a time range, or points in time, or both. Can be null
112     * @param locale the locale code used for localizing messages
113     * @return The value returned by the underlying method. Usually a {@code jdk.nashorn.api.scripting.ScriptObjectMirror}
114     * @throws ScriptException if an error occurs during invocation of the underlying method
115     * @throws NoSuchMethodException if underlying method with given name or matching argument types cannot be found
116     * @since 13147
117     */
118    public Object parse(String value, String tagKey, CheckMode mode, String locale) throws ScriptException, NoSuchMethodException {
119        return ((Invocable) ENGINE).invokeFunction("oh", value, tagKey, mode != null ? mode.code : -1, locale);
120    }
121
122    @SuppressWarnings("unchecked")
123    protected List<Object> getList(Object obj) throws ScriptException, NoSuchMethodException {
124        if (obj == null || "".equals(obj)) {
125            return Arrays.asList();
126        } else if (obj instanceof String) {
127            final Object[] strings = ((String) obj).split("\\\\n");
128            return Arrays.asList(strings);
129        } else if (obj instanceof List) {
130            return (List<Object>) obj;
131        } else {
132            // recursively call getList() with argument converted to newline-separated string
133            return getList(((Invocable) ENGINE).invokeMethod(obj, "join", "\\n"));
134        }
135    }
136
137    /**
138     * An error concerning invalid syntax for an "opening_hours"-like tag.
139     */
140    public class OpeningHoursTestError {
141        private final Severity severity;
142        private final String message;
143        private final String prettifiedValue;
144
145        /**
146         * Constructs a new {@code OpeningHoursTestError} with a known pretiffied value.
147         * @param message The error message
148         * @param severity The error severity
149         * @param prettifiedValue The prettified value
150         */
151        public OpeningHoursTestError(String message, Severity severity, String prettifiedValue) {
152            this.message = message;
153            this.severity = severity;
154            this.prettifiedValue = prettifiedValue;
155        }
156
157        /**
158         * Returns the real test error given to JOSM validator.
159         * @param p The incriminated OSM primitive.
160         * @param key The incriminated key, used for display.
161         * @return The real test error given to JOSM validator. Can be fixable or not if a prettified values has been determined.
162         */
163        public TestError getTestError(final OsmPrimitive p, final String key) {
164            final TestError.Builder error = TestError.builder(OpeningHourTest.this, severity, 2901)
165                    .message(tr("Opening hours syntax"), message) // todo obtain English message for ignore functionality
166                    .primitives(p);
167            if (prettifiedValue == null || prettifiedValue.equals(p.get(key))) {
168                return error.build();
169            } else {
170                return error.fix(() -> new ChangePropertyCommand(p, key, prettifiedValue)).build();
171            }
172        }
173
174        /**
175         * Returns the error message.
176         * @return The error message.
177         */
178        public String getMessage() {
179            return message;
180        }
181
182        /**
183         * Returns the prettified value.
184         * @return The prettified value.
185         */
186        public String getPrettifiedValue() {
187            return prettifiedValue;
188        }
189
190        /**
191         * Returns the error severity.
192         * @return The error severity.
193         */
194        public Severity getSeverity() {
195            return severity;
196        }
197
198        @Override
199        public String toString() {
200            return getMessage() + " => " + getPrettifiedValue();
201        }
202    }
203
204    /**
205     * Checks for a correct usage of the opening hour syntax of the {@code value} given according to
206     * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns a list containing
207     * validation errors or an empty list. Null values result in an empty list.
208     * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times"). Used in error message
209     * @param value the opening hour value to be checked.
210     * @return a list of {@link TestError} or an empty list
211     */
212    public List<OpeningHoursTestError> checkOpeningHourSyntax(final String key, final String value) {
213        return checkOpeningHourSyntax(key, value, null, false, LanguageInfo.getJOSMLocaleCode());
214    }
215
216    /**
217     * Checks for a correct usage of the opening hour syntax of the {@code value} given according to
218     * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns a list containing
219     * validation errors or an empty list. Null values result in an empty list.
220     * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times").
221     * @param value the opening hour value to be checked.
222     * @param mode whether to validate {@code value} as a time range, or points in time, or both. Can be null
223     * @param ignoreOtherSeverity whether to ignore errors with {@link Severity#OTHER}.
224     * @param locale the locale code used for localizing messages
225     * @return a list of {@link TestError} or an empty list
226     */
227    public List<OpeningHoursTestError> checkOpeningHourSyntax(final String key, final String value, CheckMode mode,
228            boolean ignoreOtherSeverity, String locale) {
229        if (ENGINE == null || value == null || value.isEmpty()) {
230            return Collections.emptyList();
231        }
232        final List<OpeningHoursTestError> errors = new ArrayList<>();
233        try {
234            final Object r = parse(value, key, mode, locale);
235            String prettifiedValue = null;
236            try {
237                prettifiedValue = getOpeningHoursPrettifiedValues(r);
238            } catch (ScriptException | NoSuchMethodException e) {
239                Logging.warn(e);
240            }
241            for (final Object i : getOpeningHoursErrors(r)) {
242                errors.add(new OpeningHoursTestError(getErrorMessage(key, i), Severity.ERROR, prettifiedValue));
243            }
244            for (final Object i : getOpeningHoursWarnings(r)) {
245                errors.add(new OpeningHoursTestError(getErrorMessage(key, i), Severity.WARNING, prettifiedValue));
246            }
247            if (!ignoreOtherSeverity && errors.isEmpty() && prettifiedValue != null && !value.equals(prettifiedValue)) {
248                errors.add(new OpeningHoursTestError(tr("opening_hours value can be prettified"), Severity.OTHER, prettifiedValue));
249            }
250        } catch (ScriptException | NoSuchMethodException ex) {
251            Logging.error(ex);
252            new Notification(Utils.getRootCause(ex).getMessage()).setIcon(JOptionPane.ERROR_MESSAGE).show();
253        }
254        return errors;
255    }
256
257    /**
258     * Returns the prettified value returned by the opening hours parser.
259     * @param r result of {@link #parse}
260     * @return the prettified value returned by the opening hours parser
261     * @throws NoSuchMethodException if method "prettifyValue" or matching argument types cannot be found
262     * @throws ScriptException if an error occurs during invocation of the JavaScript method
263     * @since 13296
264     */
265    public final String getOpeningHoursPrettifiedValues(Object r) throws NoSuchMethodException, ScriptException {
266        return (String) ((Invocable) ENGINE).invokeMethod(r, "prettifyValue");
267    }
268
269    /**
270     * Returns the list of errors returned by the opening hours parser.
271     * @param r result of {@link #parse}
272     * @return the list of errors returned by the opening hours parser
273     * @throws NoSuchMethodException if method "getErrors" or matching argument types cannot be found
274     * @throws ScriptException if an error occurs during invocation of the JavaScript method
275     * @since 13296
276     */
277    public final List<Object> getOpeningHoursErrors(Object r) throws NoSuchMethodException, ScriptException {
278        return getList(((Invocable) ENGINE).invokeMethod(r, "getErrors"));
279    }
280
281    /**
282     * Returns the list of warnings returned by the opening hours parser.
283     * @param r result of {@link #parse}
284     * @return the list of warnings returned by the opening hours parser
285     * @throws NoSuchMethodException if method "getWarnings" or matching argument types cannot be found
286     * @throws ScriptException if an error occurs during invocation of the JavaScript method
287     * @since 13296
288     */
289    public final List<Object> getOpeningHoursWarnings(Object r) throws NoSuchMethodException, ScriptException {
290        return getList(((Invocable) ENGINE).invokeMethod(r, "getWarnings"));
291    }
292
293    /**
294     * Translates and shortens the error/warning message.
295     * @param o error/warning message returned by {@link #getOpeningHoursErrors} or {@link #getOpeningHoursWarnings}
296     * @return translated/shortened error/warning message
297     * @since 13298
298     */
299    public static String getErrorMessage(Object o) {
300        return o.toString().trim()
301        .replace("Unexpected token:", tr("Unexpected token:"))
302        .replace("Unexpected token (school holiday parser):", tr("Unexpected token (school holiday parser):"))
303        .replace("Unexpected token in number range:", tr("Unexpected token in number range:"))
304        .replace("Unexpected token in week range:", tr("Unexpected token in week range:"))
305        .replace("Unexpected token in weekday range:", tr("Unexpected token in weekday range:"))
306        .replace("Unexpected token in month range:", tr("Unexpected token in month range:"))
307        .replace("Unexpected token in year range:", tr("Unexpected token in year range:"))
308        .replace("This means that the syntax is not valid at that point or it is currently not supported.", tr("Invalid/unsupported syntax."));
309    }
310
311    /**
312     * Translates and shortens the error/warning message.
313     * @param key OSM key
314     * @param o error/warning message returned by {@link #getOpeningHoursErrors} or {@link #getOpeningHoursWarnings}
315     * @return translated/shortened error/warning message
316     */
317    static String getErrorMessage(String key, Object o) {
318        return key + " - " + getErrorMessage(o);
319    }
320
321    protected void check(final OsmPrimitive p, final String key) {
322        for (OpeningHoursTestError e : checkOpeningHourSyntax(key, p.get(key))) {
323            errors.add(e.getTestError(p, key));
324        }
325    }
326
327    @Override
328    public void check(final OsmPrimitive p) {
329        if (p.isTagged()) {
330            check(p, "opening_hours");
331            check(p, "collection_times");
332            check(p, "service_times");
333        }
334    }
335}