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